Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c9b9941
feat(shell): add str_replace op and coder::context workspace snapshot
ytallo Jul 5, 2026
825219c
feat(harness): code mode — native coding toolset, env block, coding i…
ytallo Jul 5, 2026
70378d2
feat(console): code mode in the chat composer
ytallo Jul 5, 2026
1ece340
fix(shell): coder::context resolves the fs_scope root, not the jail p…
ytallo Jul 5, 2026
4aab82a
feat(harness): code prompt — environment block is pre-fetched, skip r…
ytallo Jul 5, 2026
4f1c692
feat(shell): coder::apply-patch — V4A whole-patch applier
ytallo Jul 5, 2026
628ab20
feat(shell): coder::worktree-add / coder::worktree-remove
ytallo Jul 5, 2026
0b8ccf5
feat(harness): worktree-isolated spawns + GPT-family code prompt
ytallo Jul 5, 2026
0b602aa
feat(approval-gate): auto-mode trust for the read-only coder surface
ytallo Jul 5, 2026
8ad3d83
feat(shell): post_write_checks — report-only diagnostics after coder …
ytallo Jul 5, 2026
111cd7a
chore(shell): clippy — redundant closures in worktree handlers
ytallo Jul 5, 2026
b570d58
fix(harness): worktree-isolated children are told to commit their work
ytallo Jul 5, 2026
c268217
feat: edit-shape fidelity — routed provider family + freeform apply_p…
ytallo Jul 6, 2026
7b78927
feat(shell): write journal + coder::undo / coder::checkpoints
ytallo Jul 6, 2026
fad6dd6
feat(console): coder views for apply-patch, checks, context, worktrees
ytallo Jul 6, 2026
8af6a62
feat(harness): harness::todo — the session's live task checklist
ytallo Jul 6, 2026
e37f5b8
fix(harness): provider resolution scans the router catalog
ytallo Jul 6, 2026
e60ece8
fix(harness): harness::todo accepts TodoWrite-shaped payloads
ytallo Jul 6, 2026
ac09330
perf(harness): concurrent read-only dispatch + fewer model round trips
ytallo Jul 6, 2026
c7a71ff
feat(approval-gate): coder write payloads survive the excerpt unclipped
ytallo Jul 6, 2026
2188f23
feat(console): checkpoints dialog with per-turn undo + medium thinkin…
ytallo Jul 6, 2026
66026d6
Merge remote-tracking branch 'origin/main' into feat/code-mode
ytallo Jul 6, 2026
8b83340
fix(console): checkpoints dialog — redo works on every revert row, da…
ytallo Jul 6, 2026
8d9440d
fix(console): model-catalog migration no longer re-sorts every chat t…
ytallo Jul 6, 2026
3f39d3f
Merge remote-tracking branch 'origin/main' into feat/code-mode
ytallo Jul 6, 2026
fbb95e9
fix(console): checkpoints dialog scopes to the conversation's session
ytallo Jul 6, 2026
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
43 changes: 32 additions & 11 deletions approval-gate/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::permissions::{default_rule_specs, parse_rules_from_config, Permissions, RuleSpec};
use crate::permissions::{parse_rules_from_config, Permissions, RuleSpec};
use crate::types::PermissionMode;

/// The shipped permission rules as a JSON array of shorthand strings — the
Expand All @@ -20,13 +20,7 @@ use crate::types::PermissionMode;
/// [`crate::permissions::default_rule_specs`] so the in-memory defaults and
/// the console-editable list never drift.
pub fn default_rules_value() -> Vec<Value> {
default_rule_specs()
.into_iter()
.filter_map(|r| match r {
RuleSpec::Shorthand(s) => Some(Value::String(s)),
_ => None,
})
.collect()
crate::permissions::default_rules::default_rule_values()
}

fn default_rules() -> Vec<Value> {
Expand All @@ -50,8 +44,27 @@ fn rules_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema
"type": "array",
"description": "Permission rules for the gate hook (first match wins). Each entry is a string or object.\n\nString shorthands: bare id/glob → allow; prefix ! → deny; no match → hold.\n\nStructured objects: { \"function\": \"shell::*\", \"action\": \"allow\" | \"deny\", \"modes\": [\"auto\"] } — optional modes scope the rule to manual, auto, or full (omit for all modes). Use modes: [\"auto\"] on allow rules to seed the auto-mode trust list.\n\nExamples:\n• \"state::get\" — allow reads\n• \"shell::*\" — allow any shell worker call\n• \"!approval::*\" — deny the approval decision plane (shipped default)\n• { \"function\": \"web::fetch\", \"action\": \"allow\", \"modes\": [\"auto\"] } — auto-mode trust",
"items": {
"type": "string",
"description": "Function id or glob. Allow: \"web::fetch\", \"coder::*\". Deny: \"!configuration::*\", \"!router::chat\"."
"oneOf": [
{
"type": "string",
"description": "Function id or glob. Allow: \"web::fetch\", \"coder::*\". Deny: \"!configuration::*\", \"!router::chat\"."
},
{
"type": "object",
"description": "Structured rule; optional modes scope it to session permission modes (auto-scoped allows also seed the auto-mode trust list).",
"required": ["function", "action"],
"properties": {
"rule_id": { "type": "string" },
"function": { "type": "string" },
"action": { "type": "string", "enum": ["allow", "deny"] },
"modes": {
"type": "array",
"items": { "type": "string", "enum": ["manual", "auto", "full"] }
},
"args": { "type": "object" }
}
}
]
},
"default": default_rules_value(),
}))
Expand Down Expand Up @@ -198,7 +211,15 @@ mod tests {
let cfg = WorkerConfig::default();
assert_eq!(cfg.default_mode, PermissionMode::Manual);
assert!(!cfg.rules.is_empty());
assert!(cfg.auto_allow_seed().is_empty());
// The shipped defaults seed auto-mode trust for the read-only
// coder surface (and nothing else).
assert_eq!(
cfg.auto_allow_seed(),
crate::permissions::default_rules::READ_ONLY_CODER
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
);
assert_eq!(cfg.grant_reask_limit, 3);
}

Expand Down
4 changes: 2 additions & 2 deletions approval-gate/src/functions/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::denial::{gate_unavailable_envelope, human_only_denial, permissions_de
use crate::error::ApprovalError;
use crate::pending;
use crate::permissions::Decision;
use crate::redact::redact;
use crate::redact::redact_for;
use crate::session;
use crate::settings;
use crate::types::{
Expand Down Expand Up @@ -125,7 +125,7 @@ async fn hold(deps: &Deps, input: &HookInput, call: &HookCall) -> HookOutput {
turn_id: input.turn_id.clone(),
function_call_id: call.id.clone(),
function_id: call.function_id.clone(),
arguments_excerpt: redact(&call.arguments),
arguments_excerpt: redact_for(&call.function_id, &call.arguments),
pending_at,
session_title,
session_description,
Expand Down
88 changes: 85 additions & 3 deletions approval-gate/src/permissions/default_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,52 @@
//! `approval-gate` configuration entry omits `rules` (or they fail to
//! compile). First match wins; no match → **hold**.
//!
//! The shipped defaults deny **only this worker's `approval::*` surface**
//! (the 12 registered functions — see `functions::catalog`). Every
//! other function_id holds until the operator adds custom `rules`.
//! The shipped defaults:
//! - deny **only this worker's `approval::*` surface** (the registered
//! functions — see `functions::catalog`);
//! - allow the READ-ONLY coder surface in **auto mode only** (context /
//! info / read-file / search / tree / list-folder), so an auto-mode
//! coding session prompts on writes and exec, not on every read.
//! These auto-scoped allows also feed `auto_allow_seed`. Manual mode
//! is unaffected — mode-scoped rules do not match there.
//!
//! Every other function_id holds until the operator adds custom `rules`.

use serde_json::{json, Value};

/// The read-only coder functions trusted in auto mode by default.
pub const READ_ONLY_CODER: [&str; 6] = [
"coder::context",
"coder::info",
"coder::read-file",
"coder::search",
"coder::tree",
"coder::list-folder",
];

/// Shorthand rule strings compiled into [`super::default_permissions`].
pub fn default_rule_strings() -> Vec<&'static str> {
vec!["!approval::*"]
}

/// The full default `rules` array as configuration JSON values:
/// shorthands plus the structured auto-scoped read-only coder allows.
pub fn default_rule_values() -> Vec<Value> {
let mut out: Vec<Value> = default_rule_strings()
.into_iter()
.map(|s| Value::String(s.to_string()))
.collect();
for fid in READ_ONLY_CODER {
out.push(json!({
"rule_id": format!("seed-auto-{}", fid.replace("::", "-")),
"function": fid,
"action": "allow",
"modes": ["auto"],
}));
}
out
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -27,6 +64,51 @@ mod tests {
assert_eq!(default_rule_strings(), ["!approval::*"]);
}

#[test]
fn default_values_add_auto_scoped_read_only_coder_allows() {
let values = default_rule_values();
assert_eq!(values.len(), 1 + READ_ONLY_CODER.len());
for v in &values[1..] {
assert_eq!(v["action"], "allow");
assert_eq!(v["modes"], json!(["auto"]));
}
}

#[test]
fn read_only_coder_allows_in_auto_hold_in_manual() {
use crate::permissions::{parse_rules_from_config, Permissions};
let p = Permissions::compile(&parse_rules_from_config(&Value::Array(
default_rule_values(),
)))
.unwrap();
for fid in READ_ONLY_CODER {
assert!(
matches!(
p.check(fid, &json!({}), PermissionMode::Auto),
Decision::Allow { .. }
),
"{fid} should allow in auto"
);
assert!(
matches!(
p.check(fid, &json!({}), PermissionMode::Manual),
Decision::NeedsApproval
),
"{fid} should still hold in manual"
);
}
// Writes and exec stay gated even in auto.
for fid in ["coder::update-file", "coder::apply-patch", "shell::exec"] {
assert!(
matches!(
p.check(fid, &json!({}), PermissionMode::Auto),
Decision::NeedsApproval
),
"{fid} should hold in auto"
);
}
}

#[test]
fn default_rules_deny_every_registered_function() {
let p = default_permissions();
Expand Down
7 changes: 2 additions & 5 deletions approval-gate/src/permissions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! Rules live in the `approval-gate` configuration entry (`GateDefaults::rules`).

mod compile;
mod default_rules;
pub mod default_rules;
mod types;

pub use compile::{
Expand Down Expand Up @@ -171,10 +171,7 @@ pub fn parse_rules_from_config(value: &Value) -> Vec<RuleSpec> {
}

pub fn default_rule_specs() -> Vec<RuleSpec> {
default_rules::default_rule_strings()
.into_iter()
.map(|s| RuleSpec::Shorthand(s.to_string()))
.collect()
parse_rules_from_config(&Value::Array(default_rules::default_rule_values()))
}

pub fn default_permissions() -> Permissions {
Expand Down
81 changes: 74 additions & 7 deletions approval-gate/src/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,62 @@ pub fn clip(s: &str) -> String {
}
}

/// Coder write functions whose arguments must reach approval UIs intact —
/// a clipped patch or content op can't render as a diff preview. Secret-key
/// redaction still applies; `FULL_PAYLOAD_BUDGET_BYTES` guards notification
/// channels against unbounded records.
const FULL_PAYLOAD_FUNCTIONS: [&str; 5] = [
"coder::update-file",
"coder::create-file",
"coder::apply-patch",
"coder::move-file",
"coder::delete-file",
];

pub const FULL_PAYLOAD_BUDGET_BYTES: usize = 64 * 1024;

/// Function-aware excerpt: coder writes keep their payload unclipped (up to
/// the budget) so approval cards can show real diffs; everything else gets
/// the classic clipped excerpt.
pub fn redact_for(function_id: &str, value: &Value) -> Value {
if FULL_PAYLOAD_FUNCTIONS.contains(&function_id) {
let full = redact_at(value, 0, false);
let within_budget = serde_json::to_string(&full)
.map(|s| s.len() <= FULL_PAYLOAD_BUDGET_BYTES)
.unwrap_or(false);
if within_budget {
return full;
}
}
redact(value)
}

/// Walk the value tree, redacting secret-keyed values and clipping long
/// strings. Returns a brand-new tree.
pub fn redact(value: &Value) -> Value {
redact_at(value, 0)
redact_at(value, 0, true)
}

fn redact_at(value: &Value, depth: usize) -> Value {
fn redact_at(value: &Value, depth: usize, clip_strings: bool) -> Value {
match value {
Value::String(s) => Value::String(clip(s)),
Value::String(s) if clip_strings => Value::String(clip(s)),
Value::String(s) => Value::String(s.clone()),
Value::Array(_) | Value::Object(_) if depth >= MAX_REDACT_DEPTH => {
Value::String(MAX_DEPTH_SENTINEL.to_string())
}
Value::Array(items) => {
Value::Array(items.iter().map(|v| redact_at(v, depth + 1)).collect())
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|v| redact_at(v, depth + 1, clip_strings))
.collect(),
),
Value::Object(map) => Value::Object(
map.iter()
.map(|(k, v)| {
let redacted = if is_secret_key(k) {
Value::String(REDACTED.to_string())
} else {
redact_at(v, depth + 1)
redact_at(v, depth + 1, clip_strings)
};
(k.clone(), redacted)
})
Expand Down Expand Up @@ -161,6 +195,39 @@ mod tests {
assert_eq!(depth, MAX_REDACT_DEPTH);
}

#[test]
fn coder_writes_keep_payload_unclipped() {
let patch = "x".repeat(5000);
let out = redact_for("coder::apply-patch", &json!({ "patch": patch }));
assert_eq!(out["patch"].as_str().unwrap().len(), 5000);
// Secrets still redact in full mode.
let out = redact_for(
"coder::create-file",
&json!({ "content": "c".repeat(400), "api_key": "sk" }),
);
assert_eq!(out["api_key"], json!("<redacted>"));
assert_eq!(out["content"].as_str().unwrap().len(), 400);
}

#[test]
fn non_coder_functions_still_clip() {
let out = redact_for("shell::exec", &json!({ "cmd": "y".repeat(300) }));
assert_eq!(
out["cmd"].as_str().unwrap().chars().count(),
ARGS_EXCERPT_LEN_CAP + 1
);
}

#[test]
fn full_payload_budget_falls_back_to_clipping() {
let huge = "z".repeat(FULL_PAYLOAD_BUDGET_BYTES + 1024);
let out = redact_for("coder::apply-patch", &json!({ "patch": huge }));
assert_eq!(
out["patch"].as_str().unwrap().chars().count(),
ARGS_EXCERPT_LEN_CAP + 1
);
}

#[test]
fn never_mutates_input() {
let input = json!({ "password": "x", "nested": { "api_key": "y" } });
Expand Down
Loading
Loading