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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Security is permission-first. All sensitive tool behavior must integrate with `s

* `/permissions remember allow|deny <tool-name> <arguments-json>` confirms and stores an exact rule only for an active saved session; list and revoke those rules by their stable IDs

* Routine parsed development commands and reversible new-file creation can execute without model review after configured and saved-session policy. Every remaining unresolved `auto` action receives one review using the current proven root request, the exact action and targets, origin and call identity, optional host-proven current-branch evidence, exact-copy provenance, and bounded masked terminal-safe excerpts of earlier current-turn tool results. Those excerpts are untrusted evidence and never authority; assistant prose, permission feedback, the pending tool group, later results, and historical requests do not enter review
* Routine parsed development commands and reversible new-file creation can execute without model review after configured and saved-session policy. Every remaining unresolved `auto` action receives one narrow security review using the exact action and targets, origin and call identity, optional host-proven current-branch evidence, exact-copy provenance, and bounded masked terminal-safe excerpts of earlier current-turn tool results. Prepared file mutations and static root tools omit task text. Reviewed commands, dynamic tools, and subagent actions also receive bounded canonical current, first, and recent root requests plus explicit omission counts; the reviewer may use that context only for destructive exceptions and immutable delegation scope, not general task policing. Assistant prose, permission feedback, compacted summaries, the pending tool group, later results, and tool or repository text never become authority

* A `clear` review authorizes only the exact unchanged action. A `caution` or unavailable review holds only that action, returns advice to the agent, and never opens a human permission screen, disables tools, or ends the turn

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ Security is permission-first.

* routine parsed development commands and reversible new-file creation can execute without model review after configured and saved-session policy; unknown, destructive, hidden, credential-bearing, public, and overwrite effects remain on the review or approval path

* every unresolved `auto` action receives one narrow safety review after configured policy, saved-session rules, grants, and deterministic safe authority; review input contains the current proven root request, the exact action and targets, origin and call identity, optional host-proven current-branch evidence, exact-copy provenance, and bounded masked terminal-safe excerpts of earlier current-turn tool results. Those excerpts are untrusted evidence and never authority; assistant prose, permission feedback, the pending tool group, later results, and historical requests do not enter review
* every unresolved `auto` action receives one narrow security review after configured policy, saved-session rules, grants, and deterministic safe authority; review input always contains the exact action and targets, origin and call identity, optional host-proven current-branch evidence, exact-copy provenance, and bounded masked terminal-safe excerpts of earlier current-turn tool results. Prepared file mutations and static root tools omit task text. Reviewed commands, dynamic tools, and subagent actions also receive bounded canonical current, first, and recent root requests plus explicit omission counts; the reviewer may use that context only for destructive exceptions and immutable delegation scope, not general task policing. Assistant prose, permission feedback, compacted summaries, the pending tool group, later results, and tool or repository text never become authority

* a `clear` review authorizes only the exact unchanged action; a `caution` or unavailable review holds only that action and returns advice without opening a human permission screen, disabling tools, or ending the turn

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ With `--json`, `output` contains accumulated assistant Markdown across the reque

Foreground terminal commands run with an explicit finite deadline. fx uses durable terminal sessions for services, watchers, GUI applications, and other long-lived work, and keeps captured foreground output available through an opaque bounded-read handle for the active session or `--no-save` process.

fx starts in `auto` permission mode. Routine understood development actions run directly. Each unresolved action receives one narrow safety review based on the current user request and the exact pending action. A clear result authorizes only that action. A caution or unavailable review holds the action and returns advice to the agent without opening a permission prompt or ending the turn. See [Permissions](https://fx.sh/docs/configure-fx/permissions) for other modes and persistent rules.
fx starts in `auto` permission mode. Routine understood development actions run directly. Each unresolved action receives one narrow review of the exact pending action for concrete security danger. Prepared file mutations and static tools are reviewed without task text; reviewed commands, dynamic tools, and delegated actions also receive bounded trusted root-request context. A clear result authorizes only that action. A caution or unavailable review holds the action and returns advice to the agent without opening a permission prompt or ending the turn. See [Permissions](https://fx.sh/docs/configure-fx/permissions) for other modes and persistent rules.

JSON and quiet requests stay noninteractive by default. Add `--prompt-permissions` to allow configured approval prompts when stdin is a TTY. Automatic safety review never opens that prompt. Prompt text is written to stderr, so JSON stdout stays parseable and quiet stdout stays empty. Piped or redirected stdin remains noninteractive and fails instead of waiting for approval.

Expand Down
39 changes: 30 additions & 9 deletions src/acp/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const AcpContext = struct {
/// session/set_mode changes never mutate a running turn.
captured_mode: ?[]const u8 = null,
captured_permission_mode: ?PermissionMode = null,
retain_external_root_user_turn: bool = false,

fn deinitPublishedToolCalls(self: *AcpContext) void {
var keys = self.published_tool_calls.keyIterator();
Expand Down Expand Up @@ -662,13 +663,19 @@ pub fn handlePrompt(
}
defer session.session_rt.usage.configureCheckpointSink(null);
const deps = agentRuntimeDeps(&ctx);
const current_prompt_is_root_authority = if (session.writable) |writable|
writable.external_prompt_origin == .persistent_child and
recovery_checkpoint == null
else
false;
ctx.retain_external_root_user_turn = current_prompt_is_root_authority;
var agent_config = buildAgentConfig(state, session, .{
.skills_prompt_section = skills_section,
.explicit_skills_prompt_section = explicit_skills.text,
.advertised_tool_names = tool_projection.advertised_names,
.advertised_functions = tool_projection.advertised_functions,
.custom_tool_guidance = tool_projection.custom_guidance,
});
}, current_prompt_is_root_authority);
agent_config.session_child_capability = if (session.writable) |*writable|
writable.childCapability() catch null
else
Expand Down Expand Up @@ -798,7 +805,12 @@ const AgentConfigSections = struct {
custom_tool_guidance: []const u8,
};

fn buildAgentConfig(state: *server.ServerState, session: *server.ActiveSessionState, sections: AgentConfigSections) agent_runtime.Config {
fn buildAgentConfig(
state: *server.ServerState,
session: *server.ActiveSessionState,
sections: AgentConfigSections,
current_prompt_is_external: bool,
) agent_runtime.Config {
return .{
.system_prompt = state.cfg.prompt_policy.system_prompt,
.model_prompt_overlay = state.cfg.prompt_policy.modelPromptOverlay(session.model),
Expand Down Expand Up @@ -831,7 +843,8 @@ fn buildAgentConfig(state: *server.ServerState, session: *server.ActiveSessionSt
else
false,
.current_prompt_is_root_authority = if (session.writable) |writable|
writable.external_prompt_origin == .persistent_child
writable.external_prompt_origin == .persistent_child and
current_prompt_is_external
else
false,
.context_limits = state.context_limits,
Expand Down Expand Up @@ -1346,7 +1359,7 @@ const TestReviewTurn = struct {
.pending_assistant = .{ .role = .assistant, .tool_calls = &self.tool_calls },
.target_call_id = self.tool_calls[0].id,
.origin = .root,
.current_root_request = self.root_messages[0],
.trusted_root_context = self.root_messages[0],
};
}
};
Expand Down Expand Up @@ -1696,14 +1709,20 @@ fn toolUpdateContentText(result: ToolExecutionResult) []const u8 {
fn propagateHistoryTurn(raw_ctx: *anyopaque, turn: HistoryTurn) !void {
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
if (ctx.state.active_session) |*session| {
try persistAcpHistoryTurn(ctx.alloc, session, turn);
try persistAcpHistoryTurn(
ctx.alloc,
session,
turn,
ctx.retain_external_root_user_turn,
);
}
}

fn persistAcpHistoryTurn(
alloc: Allocator,
session: *server.ActiveSessionState,
turn: HistoryTurn,
prompt_is_root_authority: bool,
) !void {
session.session_write_mutex.lockUncancelable(io_mod.getIo());
defer session.session_write_mutex.unlock(io_mod.getIo());
Expand All @@ -1714,9 +1733,11 @@ fn persistAcpHistoryTurn(
}
const writable = if (session.writable) |*value| value else return;
try subagent_resume_admission.retainExternalRootUserTurn(
session.store,
alloc,
writable,
turn,
prompt_is_root_authority,
);
if (writable.degradedTail() != null) {
const now_ms = io_mod.milliTimestamp();
Expand Down Expand Up @@ -1827,7 +1848,7 @@ test "ACP degraded history repair commits the finished turn once" {

const turn = try session_runtime.makeAssistantTurn(alloc, "hello", "done");
defer types.freeHistoryTurn(alloc, turn);
try persistAcpHistoryTurn(alloc, &session, turn);
try persistAcpHistoryTurn(alloc, &session, turn, true);

try std.testing.expect(session.writable.?.degradedTail() == null);
try std.testing.expectEqual(@as(usize, 1), session.session_rt.history.items.len);
Expand Down Expand Up @@ -4129,7 +4150,7 @@ test "ACP auto mode uses automatic review clear and caution without prompting" {
) anyerror!permission_auto_classifier.ParseOutcome {
const self: *@This() = @ptrCast(@alignCast(raw_ctx));
self.calls += 1;
self.root_text = request.review_turn.current_root_request;
self.root_text = request.review_turn.trusted_root_context;
return .{ .valid = .{
.risk = if (self.decision == .clear) .low else .high,
.decision = self.decision,
Expand Down Expand Up @@ -4229,7 +4250,7 @@ test "ACP auto mode automatic review clears or cautions prepared external file m
) anyerror!permission_auto_classifier.ParseOutcome {
const self: *@This() = @ptrCast(@alignCast(raw_ctx));
self.calls += 1;
self.root_text = request.review_turn.current_root_request;
self.root_text = request.review_turn.trusted_root_context;
self.saw_file_mutation_context = std.meta.activeTag(request.action) == .file_mutation;
return .{ .valid = .{
.risk = if (self.decision == .clear) .low else .high,
Expand Down Expand Up @@ -4419,7 +4440,7 @@ test "ACP prompt agent config carries request options from active session" {
.advertised_tool_names = &.{"read_file"},
.advertised_functions = &.{builtin_tools.read_file.model_schema},
.custom_tool_guidance = "acp custom tool guidance",
});
}, true);

try std.testing.expect(config.fast_mode);
try std.testing.expectEqual(types.ReasoningEffort.literal("high"), config.effort);
Expand Down
2 changes: 1 addition & 1 deletion src/builtins/gateway/permission_reviewer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ fn testRequest() permission_auto_classifier.ReviewRequest {
.pending_assistant = pending,
.target_call_id = "call_1",
.origin = .root,
.current_root_request = "User asked to inspect the repository.",
.trusted_root_context = "User asked to inspect the repository.",
},
.targets = &.{},
.action = .{ .tool = .{
Expand Down
53 changes: 21 additions & 32 deletions src/core/agent/runtime/orchestrator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3322,17 +3322,14 @@ fn containsImageId(image_ids: []const usize, candidate: usize) bool {
fn buildReviewTurnContext(
config: Config,
model: []const u8,
current_prompt: []const u8,
root_user_intent_context: []const u8,
current_turn_messages: []const ChatMessage,
pending_assistant: ChatMessage,
target_call_id: []const u8,
) permission_auto_classifier.ReviewTurnContext {
const current_root_request = currentRootRequest(
config,
current_prompt,
const trusted_root_context = auto_classifier_context.rootUserRequestContext(
root_user_intent_context,
);
) orelse "";
return .{
.model = model,
.pending_assistant = pending_assistant,
Expand All @@ -3341,34 +3338,11 @@ fn buildReviewTurnContext(
.root => .root,
.subagent => .subagent,
},
.current_root_request = current_root_request,
.trusted_root_context = trusted_root_context,
.current_turn_untrusted_messages = current_turn_messages,
};
}

fn currentRootRequest(
config: Config,
current_prompt: []const u8,
root_user_intent_context: []const u8,
) []const u8 {
return if (root_user_intent_context.len > 0)
auto_classifier_context.currentRootUserRequest(
root_user_intent_context,
) orelse root_user_intent_context
else if (config.origin == .root)
current_prompt
else if (config.current_prompt_is_root_authority)
current_prompt
else if (auto_classifier_context.currentRootUserRequest(
config.root_user_intent_context,
)) |request|
request
else if (config.root_user_messages.len > 0)
config.root_user_messages[config.root_user_messages.len - 1]
else
"";
}

fn appendTrustedPermissionFeedback(
alloc: Allocator,
feedback: *std.ArrayList([]const u8),
Expand Down Expand Up @@ -3539,7 +3513,24 @@ fn processQueuedPromptLoop(
.{};
defer context_delivery_state.deinit(arena);
const root_user_intent_context = switch (config.origin) {
.subagent => config.root_user_intent_context,
.subagent => if (config.current_prompt_is_root_authority)
try auto_classifier_context.buildRootUserContextFromVerifiedRequests(
arena,
job.prompt,
config.root_user_messages,
config.root_user_evidence_complete,
)
else if (config.root_user_intent_context.len > 0)
config.root_user_intent_context
else if (config.root_user_messages.len > 0)
try auto_classifier_context.buildRootUserContextFromVerifiedRequests(
arena,
config.root_user_messages[config.root_user_messages.len - 1],
config.root_user_messages,
config.root_user_evidence_complete,
)
else
"",
.root => if (job.root_user_intent_context.len > 0)
job.root_user_intent_context
else
Expand Down Expand Up @@ -6014,7 +6005,6 @@ fn processQueuedPromptLoop(
const parallel_review_context = buildReviewTurnContext(
config,
successful_gateway_model,
job.prompt,
root_user_intent_context,
within_turn_suffix.items,
pending_assistant,
Expand Down Expand Up @@ -6943,7 +6933,6 @@ fn processQueuedPromptLoop(
const review_context = buildReviewTurnContext(
config,
successful_gateway_model,
job.prompt,
root_user_intent_context,
within_turn_suffix.items,
pending_assistant,
Expand Down
10 changes: 6 additions & 4 deletions src/core/agent/runtime/tests/support.zig
Original file line number Diff line number Diff line change
Expand Up @@ -454,9 +454,11 @@ fn captureReviewAuthority(
) ![]u8 {
var captured: std.ArrayList(u8) = .empty;
errdefer captured.deinit(alloc);
if (review_turn.current_root_request.len > 0) {
try captured.appendSlice(alloc, review_turn.current_root_request);
try captured.append(alloc, '\n');
if (review_turn.trusted_root_context.len > 0) {
try captured.appendSlice(alloc, review_turn.trusted_root_context);
if (!std.mem.endsWith(u8, review_turn.trusted_root_context, "\n")) {
try captured.append(alloc, '\n');
}
}
return captured.toOwnedSlice(alloc);
}
Expand Down Expand Up @@ -1050,7 +1052,7 @@ pub const FakeAgentRuntimeDeps = struct {
try self.permission_review_origins.append(self.alloc, review_turn.origin);
try self.permission_review_root_authority_counts.append(
self.alloc,
@intFromBool(review_turn.current_root_request.len > 0),
@intFromBool(review_turn.trusted_root_context.len > 0),
);
try self.permission_review_feedback_counts.append(
self.alloc,
Expand Down
Loading
Loading