Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions docs/configuration/policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Organization policy

Cortex reads a managed policy document for organizations that need to pin
behavior for every member. The document lives in one directory, named by
`CORTEX_ORG_POLICY_DIR`, and is called `policy.json`.

```bash
export CORTEX_ORG_POLICY_DIR=/etc/cortex/policy
```

```json
{
"fast_mode": false,
"plugin_install": "require_accept_command"
}
```

Both keys are optional. A missing document, or a document without a key, leaves
that key on the host default.

## Resolution rules

Every key resolves **fail-closed**. A document that exists but cannot be read,
cannot be parsed, or carries a value that is not recognized denies the
restricted behavior. Policy failures never grant a permission.

| Situation | `fast_mode` | `plugin_install` |
|---|---|---|
| No directory, or no `policy.json` | host default | host default |
| Key absent | host default | host default |
| Key `true`, `"on"`, `"enabled"`, `"allowed"` | allowed | optional |
| Key `false`, `"off"`, any other value | disabled | required |
| Document unreadable or unparseable | disabled | required |

## `fast_mode`

When `fast_mode` is `false`, members cannot turn fast mode on.

- `/fast` and `/fast on` show `Fast mode is disabled for your organization.
Contact your admin.` followed by `Staying on Standard.`
- The session keeps its current model and settings. Nothing is re-sent.
- There is no client flag that bypasses the policy.
- Turning fast mode **off** is always allowed.
- A remote session on Standard shows `Remote · Standard`. The Fast chip appears
only while fast mode is actually on.

`/fast off` is unaffected by policy, so a member can always return to Standard.

## `plugin_install`

When `plugin_install` is `require_accept_command`, `cortex plugin install` and
`cortex plugin update` refuse to run without `--accept-command`:

```
This organization requires --accept-command for plugin installs.
Run `cortex plugin install <id> --json` to review the commands,
then pass --accept-command <sha256>.
```

The pin itself is enforced the same way for every organization, so a review
that does not match the package fails closed whether or not a policy document
exists. See [Plugins](../customization/plugins.md#pinned-command-installs).

## Audit journal

Fail-closed decisions are appended to `{cortex_home}/audit/events.jsonl`, one
JSON object per line:

```json
{"schema":1,"ts":"2026-09-14T09:26:11Z","kind":"plugin_command_accepted","detail":{"plugin":"cortex-review","version":"1.2.0","accepted_hash":"8f4c…","actual_hash":"8f4c…","action":"install","policy":"require_accept_command"}}
```

| `kind` | Written when |
|---|---|
| `plugin_command_accepted` | A reviewed command hash was accepted for an install or update |
| `instructions_omitted` | A subagent skipped user, project, or local instruction documents |
| `managed_policy_never_omitted` | A request named managed policy; it loaded anyway |

Records carry the plugin or source, the hashes, and the scope names. They never
carry prompt text, file bodies, or secrets.

## See also

- [Environment variables](../configuration/env.md)
- [Plugins](../customization/plugins.md)
- [Agents](../customization/agents.md#omitting-instruction-documents)
41 changes: 41 additions & 0 deletions docs/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ edit anything.
| `max_steps` | integer | Cap on tool-calling steps |
| `color` | string | Colour used in the TUI |
| `hidden` | bool | Hide from the default listing |
| `omit_instructions` | list | Instruction documents this agent skips. See below. |

### Tool access

Expand All @@ -100,6 +101,46 @@ tools:

Tool names are the ones in the [tools reference](../reference/tools.md).

### Omitting instruction documents

Cortex merges instruction Markdown from several places. A subagent can be told
to skip some of them, which keeps a narrow task from pulling in unrelated
context.

| Scope | Documents |
|---|---|
| `user` | `{cortex_home}/AGENTS.md` |
| `project` | the repository-root `AGENTS.md` |
| `local` | `AGENTS.md` between the repository root and the working directory |
| `managed` | organization policy. **Never omitted.** |

```yaml
---
name: reviewer
description: Reviews a diff without project-wide context
omit_instructions: [user, project]
---
```

Omission is opt-in and applies to that run only. A skipped document is never
opened, so it cannot reach the prompt by another path.

**Organization-managed policy always loads.** A request that names `managed` is
accepted, recorded, and ignored: the managed document still loads. The same is
true when the main agent passes `omit_instructions` to the `Task` tool.

```json
{"mode": "worker", "prompt": "review src/auth", "omit_instructions": ["user", "project"]}
```

An unknown scope name is an error rather than a silent no-op, so a typo cannot
omit the wrong documents.

Each omission is appended to `{cortex_home}/audit/events.jsonl` as
`instructions_omitted`, and a request that named managed policy is recorded as
`managed_policy_never_omitted`. See
[Organization policy](../configuration/policy.md#audit-journal).

## Where agent files are found

Searched in order; the first file defining a given name wins:
Expand Down
33 changes: 33 additions & 0 deletions docs/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Welcome to the Cortex Plugin System! This guide provides comprehensive documenta
## Table of Contents

- [Managing plugins](#managing-plugins)
- [Pinned command installs](#pinned-command-installs)
- [Introduction](#introduction)
- [Plugin Architecture](#plugin-architecture)
- [Plugin Manifest](#plugin-manifest-plugintoml)
Expand Down Expand Up @@ -43,6 +44,38 @@ cortex plugin publish --dry-run

In the TUI, `/plugins` manages them without leaving the session.

### Pinned command installs

`plugin install` and `plugin update` can print exactly what a package would
register, then install only that reviewed set.

```bash
cortex plugin install cortex-review --json # prints the review + command_hash
cortex plugin install cortex-review \
--accept-command 8f4c2a71e0b6d3a5c19f7b204e8a1d6f30c5b9a7e2d4816f0a3c7b5d9e1f2a46
```

`--json` prints the plugin id and version, every command with its aliases and
arguments, any hooks and tools, and a `command_hash`. The hash covers all of
them, so a manifest that changed in any way a user could notice produces a
different value.

When the hash does not match the package under install, the install stops:

```
Command hash mismatch. Manifest may have changed.
Re-run with --json and accept the new hash.
```

The mismatch is fail-closed. Nothing is written to the plugin root, the
previously installed package is left untouched, no trust is renewed, and there
is no `-y` shortcut that accepts a changed manifest. An organization can also
require the pin for every member; see
[Organization policy](../configuration/policy.md).

Every accepted hash is appended to `{cortex_home}/audit/events.jsonl` so a
review can be traced afterwards.

## Introduction

The Cortex plugin system allows developers to extend the CLI with custom functionality including:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions docs/reference/cli.commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -6424,6 +6424,20 @@
"required": false,
"short": null
},
{
"help": "Accept the exact command hash a prior `--json` review printed (sha256)",
"id": "accept_command",
"long": "accept-command",
"required": false,
"short": null
},
{
"help": "Print the command review and hash without installing",
"id": "json",
"long": "json",
"required": false,
"short": null
},
{
"help": "Local package path or registry plugin ID",
"id": "name",
Expand Down Expand Up @@ -7061,6 +7075,20 @@
"required": false,
"short": null
},
{
"help": "Accept the exact command hash a prior `--json` review printed (sha256)",
"id": "accept_command",
"long": "accept-command",
"required": false,
"short": null
},
{
"help": "Print the command review and hash without updating",
"id": "json",
"long": "json",
"required": false,
"short": null
},
{
"help": "Plugin name to update",
"id": "name",
Expand Down
47 changes: 46 additions & 1 deletion scripts/readiness/release_age.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@
ROOT = Path(__file__).resolve().parents[2]
MIN_AGE = timedelta(days=7)

# Security-advisory exceptions. A patched release published inside the
# seven-day window is allowed only while no older release carries the fix,
# and only until the release ages out on its own. Each entry is temporary:
# delete it once `expires` passes. The general age rule is not weakened, and
# yanked releases are never excused here.
ADVISORY_EXCEPTIONS = {
("rustls", "0.23.45"): {
"advisory": "RUSTSEC-2026-0285",
"alias": "GHSA-2mjx-qc3c-rqvc",
"expires": "2026-09-21T15:11:18+00:00",
},
}

def registry_packages(lock):
return {
(p["name"], p["version"]) for p in lock["package"]
Expand All @@ -25,12 +38,32 @@ def old_enough(created_at, now):
raise ValueError("Registry timestamp is missing its timezone")
return now - created >= MIN_AGE

def advisory_exception(name, version, now):
"""Return the live advisory exception for a release, or None.

The caller checks `yanked` first: this only excuses the age rule, and only
until the exception's expiry date.
"""
entry = ADVISORY_EXCEPTIONS.get((name, version))
if entry is None:
return None
raw = entry["expires"]
# Date-only values mean start-of-day UTC; full timestamps keep their offset.
if "T" in raw:
expires = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
else:
expires = datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
return entry if now < expires else None

def run(base):
base = subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "--verify", f"{base}^{{commit}}"], text=True).strip()
previous = subprocess.check_output(["git", "-C", str(ROOT), "show", f"{base}:Cargo.lock"], text=True)
added = registry_packages(tomllib.loads((ROOT / "Cargo.lock").read_text())) - registry_packages(tomllib.loads(previous))
now = datetime.now(timezone.utc)
failures = []
excused = []
for name, version in sorted(added):
request = Request(
f"https://crates.io/api/v1/crates/{name}/{version}",
Expand All @@ -39,9 +72,21 @@ def run(base):
# Fail closed when registry evidence is unavailable. Never substitute now.
with urlopen(request, timeout=30) as response:
release = json.load(response)["version"]
if release["yanked"] or not old_enough(release["created_at"], now):
if release["yanked"]:
failures.append(f"{name}@{version}: yanked or younger than seven days")
continue
if not old_enough(release["created_at"], now):
exception = advisory_exception(name, version, now)
if exception is None:
failures.append(f"{name}@{version}: yanked or younger than seven days")
else:
excused.append(
f"{name}@{version}: younger than seven days, allowed by "
f"{exception['advisory']} ({exception['alias']}) until {exception['expires']}"
)
time.sleep(1)
for line in excused:
print(f"advisory exception: {line}")
print("\n".join(failures) or f"Release-age policy passed for {len(added)} newly locked releases")
return int(bool(failures))

Expand Down
42 changes: 41 additions & 1 deletion scripts/readiness/test_release_age.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
import unittest

from release_age import old_enough, registry_packages
from release_age import ADVISORY_EXCEPTIONS, advisory_exception, old_enough, registry_packages

class ReleaseAgeTests(unittest.TestCase):
def test_boundary_and_future_releases(self):
Expand All @@ -18,3 +18,43 @@ def test_only_crates_io_releases_are_queried(self):
{"name": "crate", "version": "2", "source": "registry+https://github.com/rust-lang/crates.io-index"},
]}
self.assertEqual(registry_packages(lock), {("crate", "2")})

def test_rustls_exception_is_narrow_and_expires(self):
# Only the patched release for the advisory is excused.
inside = datetime(2026, 9, 16, tzinfo=timezone.utc)
entry = advisory_exception("rustls", "0.23.45", inside)
self.assertIsNotNone(entry)
self.assertEqual(entry["advisory"], "RUSTSEC-2026-0285")
self.assertEqual(entry["alias"], "GHSA-2mjx-qc3c-rqvc")
self.assertEqual(entry["expires"], "2026-09-21T15:11:18+00:00")
# The unpatched release is never excused.
self.assertIsNone(advisory_exception("rustls", "0.23.44", inside))
# Another crate is unaffected.
self.assertIsNone(advisory_exception("serde", "0.23.45", inside))

def test_rustls_exception_stops_applying_after_its_expiry(self):
# Exception must cover the full seven-day age window (~15:11:17Z), not
# midnight on the calendar day.
just_before = datetime(2026, 9, 21, 15, 11, 17, tzinfo=timezone.utc)
at_expiry = datetime(2026, 9, 21, 15, 11, 18, tzinfo=timezone.utc)
self.assertIsNotNone(advisory_exception("rustls", "0.23.45", just_before))
self.assertIsNone(advisory_exception("rustls", "0.23.45", at_expiry))
self.assertIsNone(
advisory_exception("rustls", "0.23.45", at_expiry + timedelta(seconds=1))
)
# Midnight on the expiry calendar day is still inside the window.
midnight = datetime(2026, 9, 21, 0, 0, 0, tzinfo=timezone.utc)
self.assertIsNotNone(advisory_exception("rustls", "0.23.45", midnight))

def test_every_exception_names_an_advisory_and_an_expiry(self):
for (name, version), entry in ADVISORY_EXCEPTIONS.items():
self.assertTrue(name and version, (name, version))
self.assertTrue(entry["advisory"].startswith("RUSTSEC-"), entry)
self.assertTrue(entry["alias"].startswith("GHSA-"), entry)
# A timezone-aware expiry parses; an exception must not be open ended.
raw = entry["expires"]
if "T" in raw:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
self.assertIsNotNone(parsed.tzinfo or timezone.utc)
else:
datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
Loading
Loading