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
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ fn presets() -> Value {
}
}
},
"notes": "Command sources run locally with existing CLI auth (gh) and require user approval. This command is already written for the shell on this computer; keep its quoting style when you change the PR number or repository."
"notes": "Command sources run locally with existing CLI auth (gh) and require user approval only in Ask mode. This command is already written for the shell on this computer; keep its quoting style when you change the PR number or repository."
},
{
"id": "repository-stats",
Expand Down Expand Up @@ -880,7 +880,7 @@ Paths are JSON Pointers (e.g. /current/temperature_2m) into the card data. With

Data sources (dataSource.kind):
- http: url, optional headers, intervalSeconds (30-86400), parse json|text. Fetched from this computer.
- command: shell command run locally on this computer with {shell}, intervalSeconds (30-86400), optional cwd (defaults to the local workspace) and timeoutSeconds (default 20, max 120), parse json|text. Requires user approval; not allowed for remote workspaces. Prefer CLIs that already hold credentials (gh, kubectl) over putting secrets in http headers. {command_guidance}
- command: shell command run locally on this computer with {shell}, intervalSeconds (30-86400), optional cwd (defaults to the local workspace) and timeoutSeconds (default 20, max 120), parse json|text. The permission system requests approval only in Ask mode; Auto Approve and Full Access need no additional confirmation. Not allowed for remote workspaces. Prefer CLIs that already hold credentials (gh, kubectl) over putting secrets in http headers. {command_guidance}

Workflow for command sources:
1. Run the exact command once with ExecCommand and confirm it prints the JSON you plan to bind (card commands additionally skip shell profiles). {command_test_guidance}
Expand Down Expand Up @@ -1083,7 +1083,7 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or
fn permission_intents(
&self,
input: &Value,
_context: &ToolUseContext,
context: &ToolUseContext,
) -> OpenBitFunResult<Vec<PermissionIntent>> {
let Some(command) = command_source(input) else {
return Ok(Vec::new());
Expand All @@ -1093,9 +1093,10 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or
vec![format!("desktop-card:command:{command}")],
);
intent.save_resources.clear();
intent
.display_metadata
.insert("requiresFreshApproval".to_string(), Value::Bool(true));
intent.display_metadata.insert(
"requiresFreshApproval".to_string(),
Value::Bool(context.requires_fresh_approval()),
);
intent.display_metadata.insert(
"description".to_string(),
Value::String(
Expand Down Expand Up @@ -1918,7 +1919,7 @@ mod tests {
}

#[test]
fn command_sources_always_require_fresh_approval() {
fn command_sources_require_fresh_approval_in_ask_mode() {
let input = json!({
"action": "create",
"card": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ impl Tool for PageDeployTool {

Requires a logged-in GitHub account. This tool is only available after account login. To create or update page content and publish, use PagePublish instead. Existing versions can also be reviewed from the Pages scene.

When the user has requested deployment, call the tool directly; the permission system requests confirmation only in Ask mode. Auto Approve and Full Access do not need an additional confirmation.

Input: slug (page path id), version_id (immutable saved version from a prior PagePublish). Returns absolute `url` plus url_path / deployed_version_id. Public links can be shared directly. Private and relay links must be opened or copied through the Pages scene/tool card so the browser receives a scoped one-time access handoff.

When telling the user the link: paste the full absolute URL and put a trailing space after it (before any punctuation or newline).
Expand Down Expand Up @@ -86,7 +88,7 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."#
fn permission_intents(
&self,
input: &Value,
_context: &ToolUseContext,
context: &ToolUseContext,
) -> OpenBitFunResult<Vec<PermissionIntent>> {
let slug = input
.get("slug")
Expand All @@ -107,9 +109,10 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."#
"permissionScope".to_string(),
Value::String("account".to_string()),
);
intent
.display_metadata
.insert("requiresFreshApproval".to_string(), Value::Bool(true));
intent.display_metadata.insert(
"requiresFreshApproval".to_string(),
Value::Bool(context.requires_fresh_approval()),
);
intent.display_metadata.insert(
"pageOperation".to_string(),
Value::String("deploy".to_string()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ impl Tool for PagePublishTool {

Requires a logged-in GitHub account. This tool is only available after account login. Published Pages can be reviewed and managed later from the Pages scene.

When you produce self-contained publishable web content (landing page, docs site, or a Page with server/worker.js) and the user is logged in, proactively ask whether they want it published to OpenBitFun Page (suggest a slug and visibility). If they already said publish/deploy/上线, proceed with permission confirmation.
When you produce self-contained publishable web content (landing page, docs site, or a Page with server/worker.js) and the user is logged in, proactively ask whether they want it published to OpenBitFun Page (suggest a slug and visibility). If they already said publish/deploy/上线, call the tool directly; the permission system requests confirmation only in Ask mode. Auto Approve and Full Access do not need an additional confirmation.

IMPORTANT — content source:
- Prefer `files` (inline path→UTF-8 content). For agent-authored pages, pass HTML/JS directly in `files` and call PagePublish. Do NOT Write/Edit page files into the user workspace just to publish, and do NOT create folders like openbitfun-page/ unless the user explicitly asked to keep a local copy.
Expand Down Expand Up @@ -127,7 +127,7 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a
fn permission_intents(
&self,
input: &Value,
_context: &ToolUseContext,
context: &ToolUseContext,
) -> OpenBitFunResult<Vec<PermissionIntent>> {
let slug = input
.get("slug")
Expand All @@ -154,16 +154,17 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a
}
);
let mut intent = PermissionIntent::new("page_publish", vec![resource]);
// Publishing decisions should stay per-call: a remembered wildcard grant could
// otherwise hide a later change from private preview to public production.
// In Ask mode, keep publishing decisions per-call so a remembered grant
// cannot hide a later change from private preview to public production.
intent.save_resources.clear();
intent.display_metadata.insert(
"permissionScope".to_string(),
Value::String("account".to_string()),
);
intent
.display_metadata
.insert("requiresFreshApproval".to_string(), Value::Bool(true));
intent.display_metadata.insert(
"requiresFreshApproval".to_string(),
Value::Bool(context.requires_fresh_approval()),
);
intent.display_metadata.insert(
"pageOperation".to_string(),
Value::String(if deploy { "publish" } else { "save" }.to_string()),
Expand Down
125 changes: 125 additions & 0 deletions src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4512,6 +4512,131 @@ mod tests {
)
}

#[cfg(feature = "tools-pages")]
#[tokio::test]
async fn presentation_tools_follow_permission_mode_without_weakening_denials() {
use crate::agentic::tools::implementations::{
DesktopCardTool, PageDeployTool, PagePublishTool,
};
use openbitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY;
use openbitfun_runtime_ports::PermissionMode;

let tools: Vec<(Box<dyn Tool>, serde_json::Value)> = vec![
(
Box::new(DesktopCardTool::new()),
json!({"action": "create", "card": {
"title": "Status", "template": {"kind": "note", "text": "Ready"},
"dataSource": {"kind": "command", "command": "git status", "intervalSeconds": 60}
}}),
),
(
Box::new(PagePublishTool::new()),
json!({"slug": "demo", "visibility": "public", "deploy": true,
"files": {"index.html": "<h1>Demo</h1>"}}),
),
(
Box::new(PageDeployTool::new()),
json!({"slug": "demo", "version_id": "v1"}),
),
];
for (tool, input) in tools {
for mode in [
PermissionMode::Ask,
PermissionMode::AutoApprove,
PermissionMode::FullAccess,
] {
let store = Arc::new(MemoryPermissionStore::default());
let manager = permission_test_manager(store);
let mut events = manager.subscribe();
let pipeline =
test_tool_pipeline().with_permission_request_manager(manager.clone());
let mut context = permission_test_context();
// Pages also work without an open workspace.
if tool.name().starts_with("Page") {
context.workspace = None;
}
context
.context_vars
.insert(PERMISSION_MODE_CONTEXT_KEY.into(), mode.as_str().into());
let mut options = ToolExecutionOptions::default();
options.auto_approve_ask = mode.auto_approve_ask();
options.permission_policy = ResolvedPermissionPolicy::new(
vec![PermissionRule::new(
"*",
"*",
if mode == PermissionMode::AutoApprove {
PermissionEffect::Ask
} else {
// Ask still requires a fresh reply even with an allow rule.
PermissionEffect::Allow
},
)],
Vec::new(),
);
let task = ToolTask::new(
test_tool_call("presentation", tool.name()),
context,
options,
);
let tool_context = pipeline.build_tool_use_context(&task, CancellationToken::new());
let intents = tool.permission_intents(&input, &tool_context).unwrap();
assert_eq!(intents.len(), 1);
assert_eq!(
intents[0].display_metadata["requiresFreshApproval"],
mode == PermissionMode::Ask
);
assert!(intents[0].save_resources.is_empty());
if mode == PermissionMode::Ask {
assert!(matches!(
pipeline
.draft_permission_plan(
task.clone(),
tool.name().into(),
intents.clone(),
tool_context.clone()
)
.await
.unwrap(),
PermissionPlanDraft::Requests(_)
));
} else {
let authorization = timeout(
Duration::from_secs(1),
pipeline.authorize_permission_intents(
&task,
tool.name(),
intents.clone(),
&tool_context,
&CancellationToken::new(),
),
)
.await
.expect("non-Ask modes must not wait for a user reply")
.unwrap();
assert!(matches!(authorization, PermissionAuthorization::Allowed));
assert!(manager.pending_requests().is_empty());
assert!(matches!(
events.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
}

let mut denied = task;
denied.options.permission_policy = ResolvedPermissionPolicy::new(
vec![PermissionRule::new("*", "*", PermissionEffect::Deny)],
Vec::new(),
);
assert!(matches!(
pipeline
.draft_permission_plan(denied, tool.name().into(), intents, tool_context)
.await
.unwrap(),
PermissionPlanDraft::Rejected { .. }
));
}
}
}

async fn wait_for_permission_request(
manager: &PermissionRequestManager,
) -> openbitfun_runtime_ports::PermissionRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ pub struct ToolUseContext {
}

impl ToolUseContext {
/// Whether a tool's per-call approval should be raised in the resolved turn mode.
/// Missing mode context keeps the interactive default for older callers.
pub(crate) fn requires_fresh_approval(&self) -> bool {
if let Some(mode) = self
.custom_data
.get(PERMISSION_MODE_CONTEXT_KEY)
.and_then(Value::as_str)
.and_then(PermissionMode::parse)
{
return mode == PermissionMode::Ask;
}
!self
.custom_data
.get(AUTO_APPROVE_ASK_CONTEXT_KEY)
.and_then(Value::as_bool)
.unwrap_or(false)
}

pub(crate) fn delegation_policy(&self) -> DelegationPolicy {
delegation_policy_from_custom_data(&self.custom_data)
}
Expand Down Expand Up @@ -1641,7 +1659,7 @@ mod context_builder_tests {

#[cfg(test)]
mod task_context_tests {
use super::build_tool_use_context_for_task;
use super::{build_tool_use_context_for_task, ToolUseContext};
use crate::agentic::core::ToolCall;
use crate::agentic::tools::pipeline::{
SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask,
Expand All @@ -1660,6 +1678,37 @@ mod task_context_tests {
use tokio_util::sync::CancellationToken;
use tool_runtime::context::PrimaryModelFacts;

#[test]
fn fresh_approval_uses_the_turn_mode_and_supports_legacy_context() {
let mut context = ToolUseContext::for_tool_listing(None, None);
assert!(context.requires_fresh_approval());
context
.custom_data
.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.into(), json!(true));
assert!(!context.requires_fresh_approval());
for (mode, requires_approval) in [
("ask", true),
("auto_approve", false),
("auto", false),
("full_access", false),
("full", false),
] {
context
.custom_data
.insert(PERMISSION_MODE_CONTEXT_KEY.into(), json!(mode));
assert_eq!(
context.requires_fresh_approval(),
requires_approval,
"{mode}"
);
}
context.custom_data.remove(AUTO_APPROVE_ASK_CONTEXT_KEY);
context
.custom_data
.insert(PERMISSION_MODE_CONTEXT_KEY.into(), json!("future-mode"));
assert!(context.requires_fresh_approval());
}

fn loaded_spec(tool_name: &str) -> LoadedDeferredToolSpec {
LoadedDeferredToolSpec {
tool_name: tool_name.to_string(),
Expand Down
Loading