Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions

## [Unreleased]

### Added

- `@<outside-path>` mentions auto-register a read-only project path grant in global settings, keyed by project identity, so subsequent reads (and reads of files under a granted directory) no longer prompt. Sensitive paths remain hard-blocked. (CL-5479)

### Planned

- Drop native model tiers; HITL vs free-reign Auto; project `@`-path grants in global settings (CL-5479)
- Local context estimate for compaction when providers omit usage (CL-4345)
- Image age → rehydratable attachment URI (CL-4349)
- Always-return subagent salvage without a default wall-clock death clock (CL-4401)
Expand Down
135 changes: 135 additions & 0 deletions docs/plans/hitl-auto-path-grants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# CL-5479 — HITL / Auto, drop native tiers, project path grants

Planning note for the product simplification tracked in CL-5479. Not a shipped
feature until the implementation PR lands and this plan is either folded into
PRODUCT/ARCHITECTURE or deleted.

## Goals

1. **Drop native model tiers** (`fast` / `standard` / `clever`).
2. **Two permission modes only:** HITL (ask) and Auto (free reign).
3. **Project path grants** for `@`-mentioned files and directories, persisted in
global settings keyed by project identity (no repo-local grant file).

## Model tiers

### Remove from core

| Surface | Today | After |
|---|---|---|
| `settings.tiers` | `Partial<Record<ProviderTier, TierConfig>>` | Gone (migrate: warn + ignore) |
| Slash `/fast` `/standard` `/clever` | Switch active model to tier | Removed |
| `/model` tiers chrome | Assign tier legs | Providers + default/favorite only |
| Agent profile `tier` | Resolve via settings.tiers | Explicit provider/model or profile only |
| `task(tier=…)` | Per-spawn override | Removed (use profile / parent model) |
| Evaluator “prefer fast tier” | Fallback chain | Prefer default or a single configured cheap model if any |

### Migration

- Load still accepts `tiers` in JSON for one release.
- Log a one-shot warning: tiers are ignored; pick a default model in `/model`.
- Next major: reject or strip the key on save.

Related prior tickets: CL-5192, CL-5200, CL-5196.

## Permission modes

### HITL (default)

Current interactive gate behavior:

- Read-only tools allow inside workspace (+ worktrees + path grants).
- Ask for side-effecting tools, outside-workspace content, secrets, network, etc.
- Hard denials stay (OOM shell patterns, catastrophic recursive rm, secret-guard
path-keyed reads/writes as today).

### Auto (free reign)

Operator-enabled unrestricted autonomy for the session (and optionally the run):

- Gate short-circuits to allow (same family as today’s `skipPermissions`).
- No permission modal for tools.
- UX must make the risk obvious when enabling (status chrome + one-line
confirmation).
- Headless default remains fail-closed unless Auto is explicitly set for that
run.

This **replaces** the current “soft auto” envelope (partial auto-allow with many
forced asks). Soft-auto rule tables become HITL-only complexity we can simplify
over follow-ups; Auto no longer consults them.

### Toggle

- SHIFT+TAB (or successor) switches HITL ↔ Auto.
- Status bar shows `HITL` or `Auto` clearly.

## Project path grants

### Trigger

When the operator sends a message containing `@path` that resolves to a real
file or directory **outside** the primary workspace (+ registered worktrees):

1. Still block sensitive paths (`.env`, keys, certs, …).
2. Inline content / directory summary into the message as today (when safe).
3. Register a **read-only grant** for the realpath:
- File → that file only.
- Directory → that directory tree.

### Storage

- Global settings only (e.g. under `~/.corbits/settings.json` or the project-key
map already used for per-project state), keyed by project identity.
- **Never** write grants into the git worktree — other users and clones must not
inherit them.

Sketch (names illustrative):

```json
{
"projectPathGrants": {
"<project-key>": [
{ "path": "/abs/benchmark", "mode": "read", "kind": "dir" },
{ "path": "/abs/notes.txt", "mode": "read", "kind": "file" }
]
}
}
```

### Gate integration

- Path restriction treats granted realpaths as in-bounds for **reads** and pure
listings.
- Writes / edits / shell mutations under a grant still **ask** in HITL
(default). Auto free-reign already allows them.
- Symlink policy: evaluate realpath at use time; only paths under a granted
realpath root (or equal to a granted file) count.

### UX

- Transcript line: `Granted read-only access to ../benchmark/ for this project.`
- `/permissions` (or settings) lists and revokes grants for the current project.

## Suggested implementation order

1. Path grants (storage + `@` resolution + path-restriction) — high user value,
smaller blast radius.
2. HITL rename + Auto free-reign (gate short-circuit) — product mode clarity.
3. Strip native model tiers (settings, slash, task, profiles, docs) — largest
surface; can land as a follow-up PR on the same branch stack.

## Out of scope for the first land

- Plugin-owned mode ladders (future; see platform plugin work).
- Repo-committed shared path grants for teams.
- Write grants from `@` (read-only only).

## Test plan (acceptance)

- Settings load with legacy `tiers` does not crash; resolution ignores tiers.
- No `/fast` `/standard` `/clever` in slash menu; `task` schema has no `tier`.
- HITL: outside content still asks; secret dump still asks.
- Auto: outside content and shell mutation do not prompt.
- `@../fixture.txt` and `@../fixture-dir/` grant and auto-allow subsequent reads
under HITL; no file written under the repo root for grants.
- Secret `@.env` remains blocked and does not create a grant.
15 changes: 15 additions & 0 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ export type Settings = {
recentModels?: ModelRef[];
// Operator-starred provider+model pairs for the models-first picker.
favoriteModels?: ModelRef[];
// Read-only outside-workspace path grants, keyed by projectKeyFor(cwd).
// Global settings only — never written under the git worktree.
projectPathGrants?: Record<
string,
Array<{ path: string; mode: "read"; kind: "file" | "dir" }>
>;
};

function modelRefKey(ref: ModelRef): string {
Expand Down Expand Up @@ -479,6 +485,13 @@ const SettingsSchema = type({
}),
"recentModels?": ModelRefSchema.array(),
"favoriteModels?": ModelRefSchema.array(),
"projectPathGrants?": type({
"[string]": type({
path: "string",
mode: "'read'",
kind: "'file' | 'dir'",
}).array(),
}),
});

// Per-entry MCP shape without the name key. The "exactly one transport" rule is
Expand Down Expand Up @@ -642,6 +655,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [
"otel",
"recentModels",
"favoriteModels",
"projectPathGrants",
] as const satisfies readonly (keyof OptionalSettingsFields)[];

/** Optional local settings keys the load path is required to consider. */
Expand Down Expand Up @@ -721,6 +735,7 @@ export async function loadSettings(path: string): Promise<Settings | null> {
otel: s.otel as Settings["otel"] | undefined,
recentModels: s.recentModels as Settings["recentModels"] | undefined,
favoriteModels: s.favoriteModels as Settings["favoriteModels"] | undefined,
projectPathGrants: s.projectPathGrants as Settings["projectPathGrants"] | undefined,
};
return {
providers: s.providers as Settings["providers"],
Expand Down
2 changes: 2 additions & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/type
import { createChatDirector } from "../agent/director.js";
import { loadAgentProfiles } from "../agent/profiles.js";
import { createPermissionGate } from "../permission/gate.js";
import { getProjectPathGrantsForCwd } from "../permission/path-grants.js";
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
import type {
ApprovalOutcome,
Expand Down Expand Up @@ -285,6 +286,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
approvals: seededApprovals,
cwd: config.cwd,
rootsProvider: createWorktreeRootsProvider(config.cwd),
getInitialPathGrants: () => getProjectPathGrantsForCwd(config.settings, config.cwd),
providerName: config.providerName,
model: config.model,
requestApproval: (request: PermissionRequest): Promise<ApprovalOutcome> =>
Expand Down
10 changes: 9 additions & 1 deletion src/permission/admin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Approval, GrantScope } from "./types.js";
import type { PermissionGate } from "./gate.js";
import type { PathGrant } from "./path-grants.js";
import {
loadProjectApprovals,
loadGlobalApprovals,
Expand All @@ -14,6 +15,9 @@ export type ScopedApproval = { scope: GrantScope; tool: string; pattern: string;
export type PermissionsAdmin = {
list: () => Promise<ScopedApproval[]>;
revoke: (entry: ScopedApproval) => Promise<void>;
// Forward mid-session read-only path grants (e.g. minted by an @mention) into
// the live permission gate so reads under them stop prompting immediately.
addPathGrants: (grants: readonly PathGrant[]) => void;
};

function toApproval(entry: ScopedApproval): Approval {
Expand Down Expand Up @@ -64,5 +68,9 @@ export function createPermissionsAdmin(gate: PermissionGate, cwd: string): Permi
await reseed();
};

return { list, revoke };
const addPathGrants = (grants: readonly PathGrant[]): void => {
gate.addPathGrants(grants);
};

return { list, revoke, addPathGrants };
}
22 changes: 22 additions & 0 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { evaluateApprovals } from "./authz-grants.js";
import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js";
import { createPathRestriction } from "./path-restriction.js";
import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js";
import type { PathGrant } from "./path-grants.js";
import { getSubAgentIdentity } from "../subagent/identity-context.js";
import {
createMcpToolPermissionRegistry,
Expand Down Expand Up @@ -218,6 +219,10 @@ export type PermissionGateOptions = {
// already knows about — so a worktree created mid-session is picked up
// without a restart.
rootsProvider?: RootsProvider;
// Seed live read-only path grants (from global settings projectPathGrants).
// The gate owns a mutable copy; mid-session @mention grants append via
// addPathGrants so the restriction cache is invalidated without restart.
getInitialPathGrants?: () => readonly PathGrant[];
// Tiers learned from connected MCP servers (tools/list annotations). Tests may
// inject a shared registry; production gates create one when omitted.
mcpTiers?: McpToolPermissionRegistry;
Expand Down Expand Up @@ -260,6 +265,9 @@ export type PermissionGate = {
// glob; a `run_shell` pattern that is not a single real command is dropped
// rather than minted.
preApprove: (tool: string, pattern: string) => void;
// Append mid-session project path grants (e.g. from an @mention) to the live
// list and invalidate the path-restriction cache so the next read honors them.
addPathGrants: (grants: readonly PathGrant[]) => void;
registerMcpClient: (client: MCPClient) => void;
unregisterMcpServer: (serverName: string) => void;
};
Expand All @@ -268,9 +276,12 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
const { requestApproval, persist, interactive, skipPermissions, providerName, model, cwd } = options;
const mcpTiers = options.mcpTiers ?? createMcpToolPermissionRegistry();
const resolvedCwd = cwd ?? process.cwd();
const livePathGrants: PathGrant[] = [...(options.getInitialPathGrants?.() ?? [])];
const pathRestriction = createPathRestriction(
resolvedCwd,
options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd),
undefined,
() => livePathGrants,
);
const isRestricted = pathRestriction.isRestricted;
let auto = options.auto;
Expand Down Expand Up @@ -592,6 +603,16 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
mcpTiers.removeToolsForServer(serverName);
};

const addPathGrants = (grants: readonly PathGrant[]): void => {
if (grants.length === 0) return;
for (const g of grants) {
if (!livePathGrants.some((x) => x.path === g.path && x.kind === g.kind && x.mode === g.mode)) {
livePathGrants.push(g);
}
}
pathRestriction.invalidate();
};

return {
evaluate,
getApprovals: () => approvals,
Expand All @@ -604,6 +625,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
auto = value;
},
preApprove,
addPathGrants,
registerMcpClient,
unregisterMcpServer,
};
Expand Down
Loading
Loading