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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Permissions are now loose by default and easy to change.** `/mode` is
renamed `/permissions` (the old name still works as an alias) and bare
`/permissions` opens a three-option picker — *Ask for approval*, *Approve for
me*, *Full Access* — instead of requiring a raw mode name. A bare interactive
`clawcodex` now starts in **Full Access**, equivalent to
`--dangerously-skip-permissions`. The chosen level persists to
`permissions.defaultMode` in `~/.clawcodex/settings.json`, so dialing
permissions *down* survives a relaunch.

Scope and safety of the new default:

- Only a real terminal gets it. `--print`/headless is unchanged (`default`) —
CI and the eval harness drive it — and so is any non-TTY launch, even
without `-p`. A persisted `defaultMode` applies to every surface, with one
exception in the safe direction: a saved *Full Access* is interactive-only
(see below).
- Explicit intent always wins: `--permission-mode`,
`--dangerously-skip-permissions`, and a persisted `defaultMode` all outrank
the implicit default, and `--allow-dangerously-skip-permissions` (which
means "available without starting in it") suppresses it.
- Suppressed entirely under a `disableBypassPermissionsMode` lockdown, and
under root outside a sandbox — where it degrades to `default` rather than
refusing to start, and also clamps a persisted `bypassPermissions` and
settings-granted bypass availability.
- **Plan mode still restrains.** Choosing Full Access sets the mode only and
never grants engine bypass *availability*, which is what relaxes `plan`, so
`/plan` keeps asking in a full-access session.
- Repository settings files (`.clawcodex/settings.json` and
`settings.local.json` — both are committable, whatever the `.local` name
suggests) may only supply `default` or `dontAsk`, and only when that would
not loosen what applies anyway. A cloned repo cannot widen your
permissions. `plan` is excluded on purpose: with bypass availability it is
a full bypass, not a restriction.
- A saved Full Access applies only to interactive sessions. Persistence
exists so dialing *down* survives a relaunch; it never dials `-p` up.
- `permissions.allowBypassPermissionsMode` is now read from the user config
only. It was also read from `<git-root>/.clawcodex/config.local.json`,
which — despite the `.local` name — is a committable repo file, so a
checked-out repository could grant itself bypass *availability* and thereby
turn `/plan` into a write-anywhere bypass. Operators who kept that key in a
repo-local config must move it to `~/.clawcodex/config.json`.
- Running as root outside a sandbox now also drops bypass availability, and
ignores a *saved* Full Access. An explicit `--permission-mode` is still
honored there; only the implicit and saved paths are clamped.
- `setMode` arriving inside a permission-ask reply (`chosen_updates`) is now
gated by the same capability as `/permissions`, and refused entirely for
persisted destinations — previously it was an ungated second door to
`bypassPermissions` for any non-TUI agent-server client.

## [1.3.0] - 2026-07-29

### Added
Expand Down
65 changes: 57 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Then configure a provider and start coding:

```bash
clawcodex login # interactive provider + API key setup → ~/.clawcodex/config.json
clawcodex --dangerously-skip-permissions # start the REPL in any project
clawcodex # start it in any project — Full Access by default, /permissions to change
```

The installer also ships `clawcodex` lifecycle helpers — `doctor` (diagnose your
Expand All @@ -104,7 +104,7 @@ pip install -r requirements.txt

python -m src.cli login # writes config to ~/.clawcodex/config.json

python -m src.cli --dangerously-skip-permissions # start the REPL
python -m src.cli # start it (Full Access by default; /permissions to change)
```

</details>
Expand Down Expand Up @@ -400,12 +400,58 @@ clawcodex -p --output-format stream-json --input-format stream-json < events.ndj
clawcodex --provider anthropic --model claude-sonnet-4-6 -p "Hi"
clawcodex --max-turns 10 --allowed-tools Read,Grep -p "Find TODOs"

# Permission control (REPL, TUI, and -p all honor these)
clawcodex --permission-mode plan # plan / acceptEdits / dontAsk
# Permission control (TUI and -p both honor these)
clawcodex --permission-mode plan # plan / default / acceptEdits / dontAsk
clawcodex --dangerously-skip-permissions -p "ls" # bypass all permission checks
clawcodex --allow-dangerously-skip-permissions # allow /permission-mode bypass later
clawcodex --allow-dangerously-skip-permissions # Full Access selectable, but don't start in it
```

### Permissions

An interactive `clawcodex` starts in **Full Access** — it can edit any file and
run any command without asking. Use `/permissions` at any time to pick a level:

| Level | What it allows |
| ----- | -------------- |
| **Ask for approval** | Read files and run read-only commands freely. Editing files, running other commands, or touching anything outside the workspace asks first. |
| **Approve for me** | Auto-accept file edits in the workspace and a small set of safe shell commands. Everything else still asks. |
| **Full Access** *(default)* | Edit any file and run any command without asking, except where you configured a deny or ask rule. |

The level you pick is saved to `permissions.defaultMode` in
`~/.clawcodex/settings.json` and applies to later sessions, so dialing
permissions down sticks. `--permission-mode` and
`--dangerously-skip-permissions` override it for a single run without changing
it.

`clawcodex -p` (headless) is **not** loose by default — it stays in `default`
unless you say otherwise, and a *saved* Full Access does not change that:
persistence dials headless down, never up. Say otherwise explicitly with
`--dangerously-skip-permissions` or `--permission-mode`. The same applies to
any launch that isn't attached to a terminal.

To start in a lower level every time, either pick it once with `/permissions`
or set it directly:

```json
// ~/.clawcodex/settings.json
{ "permissions": { "defaultMode": "default" } }
```

A repo can ship `.clawcodex/settings.json` (or `settings.local.json`) with a
`defaultMode` to *tighten* permissions for anyone who opens it. Repo-supplied
values are restricted to `default` and `dontAsk` — note `dontAsk` denies
anything that would otherwise prompt, so a repo can make the agent refuse
actions rather than ask about them — and are ignored if they'd be looser than
what would otherwise apply, so a cloned repo can never widen yours. (`plan` is
excluded deliberately: combined with bypass availability it is a full bypass,
not a restriction.) Administrators can disable Full Access outright with
`disableBypassPermissionsMode` in `~/.clawcodex/config.json` or managed policy.

`/plan` keeps restraining edits even in a Full Access session — unless you
launched with `--allow-dangerously-skip-permissions` or set
`allowBypassPermissionsMode` in your user config, both of which relax plan mode
by design.

### Claude Pro/Max subscription login

Run `clawcodex login`, choose `anthropic`, then choose `subscription`. The CLI
Expand Down Expand Up @@ -446,9 +492,12 @@ Use `clawcodex config` to check connection status and
`clawcodex logout openai` to delete the stored OAuth credentials.

> **`--dangerously-skip-permissions`** disables every tool permission check
> for the session. Recommended only inside sandboxed containers/VMs with no
> internet access. The flag is refused when the process is running as
> root/sudo unless `IS_SANDBOX=1` or `CLAUDE_CODE_BUBBLEWRAP=1` is set.
> for the session — the same thing an interactive `clawcodex` now does by
> default (see [Permissions](#permissions)). Recommended only inside sandboxed
> containers/VMs with no internet access. The *flag* is refused when the
> process is running as root/sudo unless `IS_SANDBOX=1` or
> `CLAUDE_CODE_BUBBLEWRAP=1` is set; under root the implicit default quietly
> falls back to `default` instead, so `sudo clawcodex` still starts.

***

Expand Down
47 changes: 28 additions & 19 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def main():
effort=args.effort,
permission_mode=args._resolved_permission_mode,
is_bypass_available=args._resolved_is_bypass_available,
bypass_selectable=args._resolved_bypass_selectable,
workspace=(worktree_session.worktree_path if worktree_session else None),
worktree=worktree_session,
)
Expand Down Expand Up @@ -580,41 +581,49 @@ def _resolve_permission_state(args) -> None:
from src.permissions.dangerous_safety import (
enforce_dangerous_skip_permissions_safety,
)
from src.permissions.modes import (
has_allow_bypass_permissions_mode,
initial_permission_mode_from_cli,
is_bypass_permissions_mode_disabled,
)
from src.permissions.modes import resolve_interactive_permission_state

dangerously = bool(getattr(args, 'dangerously_skip_permissions', False))
allow_dangerously = bool(getattr(args, 'allow_dangerously_skip_permissions', False))
permission_mode_cli = getattr(args, 'permission_mode', None)

# Safety gate first — refuse to run as root outside a sandbox.
# Safety gate first — refuse to run as root outside a sandbox. Applies to the
# EXPLICIT flags only; the implicit full-access default degrades to `default`
# under the same condition instead of aborting (see
# modes.resolve_interactive_permission_state), because `sudo clawcodex` with
# no flags must still start.
enforce_dangerous_skip_permissions_safety(
bypass_requested=dangerously or allow_dangerously,
)

mode = initial_permission_mode_from_cli(
# The loose default is scoped to the INTERACTIVE launch — "the user typed
# clawcodex in a terminal". `--print`/headless keeps `default` because CI
# and the Harbor eval harness drive it, and silently altering benchmark runs
# is not acceptable. A persisted `permissions.defaultMode` still applies to
# both, so an explicit user choice is never honored on one surface and
# ignored on the other.
#
# "not --print" is NOT sufficient: a TTY check is what makes "a human is
# sitting there" true. Without it any piped/automated launch that merely
# omits -p would take the loose floor — and `_gate_folder_trust` grants
# trust implicitly on non-TTY stdin, so nothing else would stop it either.
interactive = (
not bool(getattr(args, 'print', False))
and sys.stdin.isatty()
and sys.stdout.isatty()
)

mode, is_bypass_available, bypass_selectable = resolve_interactive_permission_state(
permission_mode_cli=permission_mode_cli,
dangerously_skip_permissions=dangerously,
allow_dangerously_skip_permissions=allow_dangerously,
implicit_full_access=interactive,
)

# TS isBypassPermissionsModeAvailable (permissionSetup.ts:941-946):
# (bypass-requested OR allow-key) AND NOT disabled. The negative guard was
# dropped in the port → an operator's disableBypassPermissionsMode lockdown
# was silently ignored (critic C12, a live fail-open). A disable overrides
# even an explicit --dangerously-skip-permissions, matching TS's
# unconditional `&& !settingsDisableBypassPermissionsMode`.
is_bypass_available = (
dangerously
or allow_dangerously
or has_allow_bypass_permissions_mode()
) and not is_bypass_permissions_mode_disabled()

# Stash on args so downstream entrypoints don't need to re-derive.
args._resolved_permission_mode = mode
args._resolved_is_bypass_available = is_bypass_available
args._resolved_bypass_selectable = bypass_selectable

if dangerously or allow_dangerously:
_logging.getLogger("clawcodex.permissions").info(
Expand Down
66 changes: 43 additions & 23 deletions src/command_system/permissions_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
"""permissions — interactive ``/permissions`` command (port of TS local-jsx).

.. warning::

**Test-only reachable.** The live ``/permissions`` is the Ink TUI's local
slash command (``ui-tui/src/app/slash/commands/session.ts``), which routes
through the agent-server's gated ``set_permission_mode`` control. This one is
still registered (``builtins.py``) but its two original surfaces — the Rich
REPL and the Textual TUI — were deleted in the UI consolidation, and the Ink
client never dispatches unknown slashes to the Python registry. It survives
because ``tests/test_interactive_bridge.py`` uses it as the reference command
for the whole interactive-command bridge.

Do not wire it to a live surface as-is: it writes ``AppState.permission_mode``
through the store and never touches ``tool_context``, so it applies no mode to
the live permission gate **and enforces no bypass gate** — it would be an
ungated ``bypassPermissions`` selector. Route any new surface through
``set_permission_mode`` instead.

Reference command for the P0-3 interactive-command bridge (see
my-docs/get-parity-by-folder/commands-phase2-interactive-bridge-plan.md): a
single ``select`` over the user-facing permission modes, backed by the reactive
Expand Down Expand Up @@ -28,30 +45,33 @@
UIOption,
)

# User-facing permission modes, in the Shift+Tab cycle order
# (``src/permissions/cycle.py``). The internal modes (dontAsk / auto / bubble)
# are deliberately excluded from the picker — they're not user-addressable.
def _option_for(mode: str, fallback_label: str, fallback_description: str) -> UIOption:
"""Build a :class:`UIOption` from :mod:`src.permissions.levels` when the mode
is one of the three user-facing levels, else from the supplied fallback.

Labels/descriptions come from the shared catalog so this command and the Ink
TUI's ``/permissions`` picker never present two different vocabularies for
the same modes. ``plan`` has no level row (it is a workflow mode, not a point
on the looseness ladder) and keeps its own wording.
"""
from src.permissions.levels import level_for_mode

level = level_for_mode(mode)
return UIOption(
value=mode,
label=level.label if level else fallback_label,
description=level.description if level else fallback_description,
)


# In Shift+Tab cycle order (``src/permissions/cycle.py``). The internal modes
# (dontAsk / auto / bubble) are deliberately excluded — they're not
# user-addressable.
_PERMISSION_MODE_OPTIONS: list[UIOption] = [
UIOption(
value="default",
label="default",
description="Prompt before each tool's first use",
),
UIOption(
value="acceptEdits",
label="acceptEdits",
description="Auto-accept file edits in the workspace",
),
UIOption(
value="plan",
label="plan",
description="Plan only — no edits or commands",
),
UIOption(
value="bypassPermissions",
label="bypassPermissions",
description="Skip all permission prompts",
),
_option_for("default", "default", "Prompt before each tool's first use"),
_option_for("acceptEdits", "acceptEdits", "Auto-accept file edits in the workspace"),
_option_for("plan", "Plan mode", "Plan only — no edits or commands"),
_option_for("bypassPermissions", "bypassPermissions", "Skip all permission prompts"),
]


Expand Down
37 changes: 32 additions & 5 deletions src/entrypoints/agent_server_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ def _sanitize_agent_server_mode(args: Any, dangerously: bool) -> None:

The agent-server subcommand sets the mode STRAIGHT into AgentServerConfig
(it does NOT route through ``initial_permission_mode_from_cli``, so that
function's candidate-skip doesn't apply). ``check.py:456`` bypasses on the
mode ALONE, so BOTH ``--dangerously-skip-permissions`` and a directly-passed
function's candidate-skip doesn't apply). ``check.py``'s ``should_bypass``
bypasses on the mode ALONE, so BOTH ``--dangerously-skip-permissions`` and a directly-passed
``--permission-mode bypassPermissions`` must be sanitized here or they defeat
a managed ``disableBypassPermissionsMode: "disable"`` lockdown. Downgrade
-and-warn (TS ``continue`` semantics), never a hard error."""
Expand Down Expand Up @@ -155,8 +155,16 @@ def run_agent_server_subcommand(argv: list[str]) -> int:
parser.add_argument(
"--allow-dangerously-skip-permissions", action="store_true",
dest="allow_dangerously_skip_permissions",
help="Make bypassPermissions AVAILABLE (Shift+Tab / /mode) without "
"starting in it.",
help="Make bypassPermissions AVAILABLE (Shift+Tab / /permissions) "
"without starting in it. Also relaxes plan mode.",
)
parser.add_argument(
"--allow-select-bypass", action="store_true",
dest="allow_select_bypass",
help="Let /permissions CHOOSE Full Access. Weaker than "
"--allow-dangerously-skip-permissions: it does not relax plan "
"mode and is not inherited as bypass availability. Set by the "
"interactive launchers; this server is a carrier, not a resolver.",
)
parser.add_argument("--workspace", default=None,
help="Workspace root the agent operates in (default: cwd).")
Expand Down Expand Up @@ -216,7 +224,7 @@ def run_agent_server_subcommand(argv: list[str]) -> int:
# bypass AVAILABILITY. Flags always count. Trusted settings
# (permissions.allowBypassPermissionsMode) count only on the
# single-session --stdio transport: this server serves exactly the
# operator who launched it, so their own user/local settings apply. On
# operator who launched it, so their own user settings apply. On
# the multi-session --http transport, folding host settings in would
# unlock bypass for every remote client — resolve that per-launch
# upstream and pass a flag instead.
Expand All @@ -232,6 +240,24 @@ def run_agent_server_subcommand(argv: list[str]) -> int:
if is_bypass_permissions_mode_disabled():
is_bypass_available = False

# bypass SELECTABILITY — whether /permissions may choose Full Access. This
# server is a CARRIER, not a resolver: the value is decided once at the
# interactive launch boundary (src/cli.py, tui_launcher) and forwarded, never
# re-derived from settings here. Re-reading settings would let the --http
# server host's own config unlock Full Access for every remote client
# session (the same hazard documented at agent_server._build_runtime).
# Availability implies selectability; a lockdown revokes both.
#
# Bounded claim: this keeps SELECTABILITY off the host's settings. It does
# not isolate the starting MODE — the launcher that spawned this server
# already resolved that from the host's own `permissions.defaultMode`, so a
# multi-tenant deployment still inherits the host's configured mode.
bypass_selectable = bool(getattr(args, "allow_select_bypass", False)) or is_bypass_available
if bypass_selectable and not is_bypass_available:
from src.permissions.modes import is_bypass_permissions_mode_disabled
if is_bypass_permissions_mode_disabled():
bypass_selectable = False

workspace = str(Path(args.workspace).resolve()) if args.workspace else str(Path.cwd())

if args.fallback_model and args.fallback_model == args.model:
Expand All @@ -245,6 +271,7 @@ def run_agent_server_subcommand(argv: list[str]) -> int:
fallback_model=args.fallback_model,
permission_mode=args.permission_mode,
is_bypass_available=is_bypass_available,
bypass_selectable=bypass_selectable,
max_turns=args.max_turns,
)

Expand Down
Loading
Loading