Skip to content
Open
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
64 changes: 64 additions & 0 deletions .forge/FORGE_PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
## Summary
Add a permission case system with required caller context and paged evidence display for write/patch/shell operations in restricted mode.

## Context
When using forge in restricted mode, the permission approval flow had three problems:

1. **No context at decision time**: The TUI permission prompt cleared the terminal, erasing all context about what was being approved
2. **No caller justification**: Write/patch operations had no way to carry the LLM's reasoning or justification through to the permission prompt
3. **No evidence inspection**: Users had no way to inspect the full proposed diff before making a decision — only a truncated single-line message was visible

This implements a judicial-style evidence collection system where every decision point is preceded by a full case brief showing what changed, why, and where to find the full details.

## Changes

### Permission Case System (new: `crates/forge_domain/src/policies/case.rs`)
- `PermissionCase` struct collects all decision evidence: case_id, timestamp, operation type, file path, proposed changes, and caller explanation
- Atomic counter ensures unique case IDs across rapid sequential decisions
- `format_panel()` renders a styled evidence panel for display
- Tests verify case creation, ID uniqueness, panel rendering, and patch diff display

### Required `context` field on tool calls (`crates/forge_domain/src/tools/catalog.rs`)
- `FSWrite`, `FSPatch`, `FSMultiPatch` now require a `context: String` field
- The LLM must provide a justification for every write/patch (enforced via JSON schema, no `#[serde(default)]`)
- `Shell` also requires `context: String` for command reasoning
- `CommandType` enum classifies shell commands: `InlineCode` (here-docs, eval, embedded scripts — auto-denied), `FileScript`, `Utility`
- All snapshots and test fixtures updated for the new schema

### Paged evidence display (`crates/forge_app/src/tool_registry.rs`)
- `build_case()` constructs the full case from tool input, collecting untruncated content
- `print_case()` writes the case brief to `/tmp/forge-cases/<case_id>.md` and pipes it through `less` (respects `$PAGER` env)
- After the user quits the pager, the TUI permission prompt appears
- `check_tool_permission()` appends the case file path to the TUI message for later reference
- Inline-code shell commands are auto-denied before reaching the TUI
- Full untruncated diffs shown (removed 120-char truncation limits since content is now paged)

### Permission operation message field (`crates/forge_domain/src/policies/operation.rs`)
- `PermissionOperation::Execute` now carries a `message: String` for classification info
- All pattern matches updated (`policy.rs`, `rule.rs`, `engine.rs`)

### `fmt_input.rs` enhancements
- Write/Patch/MultiPatch tool input display now shows proposed content/diff inline

## Use Cases
- **Write approval**: See full file content in `less` before allowing the write
- **Patch approval**: Scroll through the complete old→new diff in the pager before deciding
- **Shell execution**: See the command type classification (auto-deny inline code)
- **Audit trail**: Every permission decision has a case file in `/tmp/forge-cases/` with full evidence

## Testing
```bash
# Run all relevant tests
cargo test -p forge_domain -p forge_app -p forge_services

# All 1532 tests pass, 0 failures

# To test interactively:
# 1. Start forge in restricted mode
# 2. Request a write or patch operation
# 3. The pager (less) should show the full evidence before the TUI prompt
# 4. Check /tmp/forge-cases/ for the persistent case file
```

## Links
- Based on discussion about judicial-style approval workflow with evidence collection and traceable decision IDs
1 change: 1 addition & 0 deletions Cargo.lock

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

77 changes: 77 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# forge development Makefile
#
# All build commands run inside `nix develop` so protoc, cargo, and
# other dependencies are available automatically.
#
# Use `nix develop` first if you want an interactive shell, or prefix
# any target below with `nix develop --command` if running outside nix.
#
# Usage:
# make <target> — runs inside nix develop automatically

SHELL := bash
NIX_CMD := nix develop --command

# ── Build ──────────────────────────────────────────────────────────

.PHONY: build
build: ## Build the forge binary via nix
nix build .#forge

.PHONY: check
check: ## Check all crates compile (fast)
$(NIX_CMD) cargo check

.PHONY: check-app
check-app: ## Check only the forge_app crate
$(NIX_CMD) cargo check -p forge_app

.PHONY: check-services
check-services: ## Check only the forge_services crate
$(NIX_CMD) cargo check -p forge_services

.PHONY: clippy
clippy: ## Run clippy on all crates
$(NIX_CMD) cargo clippy -- -D warnings

# ── Test ───────────────────────────────────────────────────────────

.PHONY: test
test: ## Run all tests
$(NIX_CMD) cargo test

.PHONY: test-app
test-app: ## Run all forge_app tests
$(NIX_CMD) cargo test -p forge_app

.PHONY: test-services
test-services: ## Run all forge_services tests
$(NIX_CMD) cargo test -p forge_services

.PHONY: test-tool-registry
test-tool-registry: ## Run tool_registry unit tests
$(NIX_CMD) cargo test -p forge_app --lib -- tool_registry

.PHONY: test-policy
test-policy: ## Run policy service tests
$(NIX_CMD) cargo test -p forge_services --lib -- policy

.PHONY: test-instas
test-instas: ## Run and accept insta snapshot tests
$(NIX_CMD) cargo insta test --accept

# ── Utility ────────────────────────────────────────────────────────

.PHONY: fmt
fmt: ## Format all Rust code
$(NIX_CMD) cargo fmt

.PHONY: clean
clean: ## Clean build artifacts
cargo clean

.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
162 changes: 162 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,168 @@ MCP tools can be used as part of multi-agent workflows, allowing specialized age

---

## Permission System

Forge includes a policy-based permission system that lets you control what operations the AI agent can perform. The system evaluates every tool call — reads, writes, command execution, and network fetches — against a set of configurable rules before allowing it.

### Permission Levels

Every operation is classified into one of three permission outcomes:

| Level | Behavior |
|-----------|--------------------------------------------------------------------------|
| **Allow** | The operation proceeds without user interaction |
| **Deny** | The operation is blocked; the agent receives a denial message |
| **Confirm** | The user is prompted to Accept, Reject, or Accept and Remember the operation |

### Operation Types

The permission system gates four operation types:

| Operation | Description |
|-------------|--------------------------------------|
| **Read** | Reading file contents and search operations |
| **Write** | Writing, patching, and removing files |
| **Execute** | Running shell commands |
| **Fetch** | Making network/URL requests |

### Restricted Mode

By default, Forge operates with open permissions (all operations allowed). To enable the permission system, set `restricted` to `true` in your `forge.toml`:

```toml
# forge.toml
restricted = true
```

When restricted mode is active, every tool call is checked against the policy engine before execution:

1. **Preview**: For operations requiring confirmation, the proposed changes are displayed inline (diff for patches, file content for writes, command text for executions, URL for fetches)
2. **Decision**: The user is prompted to Accept, Reject, or Accept and Remember
3. **Policy creation**: "Accept and Remember" dynamically creates a new allow rule for similar operations based on file extension, hostname, or command prefix

### Policy Configuration

Policies are defined in a YAML file located at `~/.forge/permissions.yaml`. Each policy pairs a permission level with a rule that matches specific operations using glob patterns:

```yaml
# ~/.forge/permissions.yaml
policies:
- permission: allow
rule:
read: "**/*.rs" # Allow reading all Rust files
- permission: confirm
rule:
write: "src/**/*" # Confirm before writing to src/
- permission: deny
rule:
command: "rm -rf /*" # Deny dangerous commands
```

**Default configuration** (created automatically on first use):

```yaml
policies:
- permission: allow
rule:
read: "**/*" # Allow reading any file
- permission: allow
rule:
write: "**/*" # Allow writing any file
- permission: allow
rule:
command: "*" # Allow any command
- permission: allow
rule:
url: "*" # Allow any URL fetch
```

### Rule Types

| Rule Type | Pattern Field | Target | Example |
|------------|---------------|----------------|-------------------------------------|
| `read` | Glob path | File reads | `read: "docs/**/*.md"` |
| `write` | Glob path | File writes | `write: "src/**/*.rs"` |
| `command` | Glob string | Shell commands | `command: "cargo *"` |
| `url` | Glob string | Network fetches| `url: "api.example.com/*"` |

All rule types support an optional `dir` field to scope the rule to a specific working directory:

```yaml
- permission: allow
rule:
command: "git *"
dir: "/home/user/projects/*" # Only applies when in a project directory
```

### Policy Composition

Policies can be composed using logical operators for more granular control:

```yaml
policies:
# Allow writes to Rust files in the src directory
- all:
- permission: allow
rule:
write: "src/**/*"
- permission: allow
rule:
write: "**/*.rs"

# Allow npm or cargo commands
- any:
- permission: allow
rule:
command: "npm *"
- permission: allow
rule:
command: "cargo *"

# Deny everything except the above
- not:
permission: allow
rule:
command: "*"
```

| Operator | YAML Key | Behavior |
|----------|----------|----------|
| **All** | `all` | All nested policies must match (most restrictive wins) |
| **Any** | `any` | First matching policy determines the outcome |
| **Not** | `not` | Inverts the permission (Allow → Deny, Deny → Allow) |

### Dynamic Policy Creation

When a user selects "Accept and Remember" during a confirmation prompt, Forge automatically creates a policy rule for similar future operations:

- **File operations**: Creates a rule matching the file extension (e.g., `*.rs`)
- **Commands**: Creates a rule matching the command and first subcommand (e.g., `git push*`)
- **URL fetches**: Creates a rule matching the hostname (e.g., `api.example.com/*`)

These dynamically created rules are appended to `~/.forge/permissions.yaml` and take effect immediately.

### Viewing Permissions

Run `forge info` to see the current location of your permissions file:

```
$ forge info
...
Policies │ ~/.forge/permissions.yaml
```

### Evaluation Order

Policies are evaluated in order. The first matching rule determines the outcome:
1. If any policy matches with **Deny** or **Confirm**, that decision is returned immediately
2. If multiple policies match with **Allow**, the last Allow is used
3. If no policies match, the operation defaults to **Confirm**
4. In restricted mode, Confirm triggers an interactive prompt; outside restricted mode,
Confirm defaults to Allow

---

## Documentation

For comprehensive documentation on all features and capabilities, please visit the [documentation site](https://github.com/tailcallhq/forgecode/tree/main/docs).
Expand Down
1 change: 1 addition & 0 deletions crates/forge_app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tempfile.workspace = true
strum.workspace = true
strum_macros.workspace = true
forge_template.workspace = true
forge_select.workspace = true
merge.workspace = true
convert_case.workspace = true
backon.workspace = true
Expand Down
47 changes: 32 additions & 15 deletions crates/forge_app/src/fmt/fmt_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,20 @@ impl FormatContent for ToolCatalog {
let path = PathBuf::from(&input.file_path);
let display_path = display_path_for(&input.file_path);
let title = match (path.exists(), input.overwrite) {
(true, true) => "Overwrite",
(true, true) => format!("Overwrite: {display_path}"),
(true, false) => {
// Case: file exists but overwrite is false then we throw error from tool,
// so it's good idea to not print anything on CLI.
return None;
}
(false, _) => "Create",
(false, _) => format!("Create: {display_path}"),
};
Some(TitleFormat::debug(title).sub_title(display_path).into())
// Show the content inline so the user has context about what will be
// written before any permission prompt
let body = if input.content.is_empty() {
"[empty content]".to_string()
} else {
format!("{title}\n\n{}", input.content)
};
Some(ChatResponseContent::ToolOutput(body))
}
ToolCatalog::FsSearch(input) => {
let formatted_dir = input.path.as_deref().unwrap_or(".");
Expand Down Expand Up @@ -90,19 +95,31 @@ impl FormatContent for ToolCatalog {
} else {
"Replace"
};
Some(
TitleFormat::debug(operation_name)
.sub_title(display_path)
.into(),
)
let body = format!(
"{operation_name}: {display_path}\n\n--- old string ---\n{}\n--- new string ---\n{}",
input.old_string, input.new_string
);
Some(ChatResponseContent::ToolOutput(body))
}
ToolCatalog::MultiPatch(input) => {
let display_path = display_path_for(&input.file_path);
Some(
TitleFormat::debug("Replace")
.sub_title(format!("{} ({} edits)", display_path, input.edits.len()))
.into(),
)
let edits: Vec<String> = input
.edits
.iter()
.map(|e| {
format!(
"- Replace \"{}…\" → \"{}…\"{}",
&e.old_string[..e.old_string.len().min(60)],
&e.new_string[..e.new_string.len().min(60)],
if e.replace_all { " (all occurrences)" } else { "" }
)
})
.collect();
Some(ChatResponseContent::ToolOutput(format!(
"Replace: {display_path} ({} edit(s))\n\n{}",
input.edits.len(),
edits.join("\n")
)))
}
ToolCatalog::Undo(input) => {
let display_path = display_path_for(&input.path);
Expand Down
Loading