Skip to content

Commit ecd5cf8

Browse files
committed
add an editor for simple network rules
Signed-off-by: Peter Jausovec <peter.jausovec@solo.io>
1 parent d74a4d0 commit ecd5cf8

25 files changed

Lines changed: 3678 additions & 154 deletions

‎Cargo.lock‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ notify = "8.2"
3838
notify-debouncer-full = "0.7"
3939
jsonwebtoken = { version = "11.0", features = ["aws_lc_rs"] }
4040
json5 = "1.3"
41+
jsonc-parser = { version = "0.33.1", features = ["cst"] }
4142
keyring = "4.2"
4243
keyring-core = "1"
4344
libc = "0.2"

‎crates/agent/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ hostname.workspace = true
2222
hyper.workspace = true
2323
hyper-util.workspace = true
2424
json5.workspace = true
25+
jsonc-parser.workspace = true
2526
memchr.workspace = true
2627
open.workspace = true
2728
rand.workspace = true

‎crates/agent/src/access/claude_code.rs‎

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@ use std::{
66

77
use agentdesktop_core::model::{
88
AccessCapability, AccessCategory, AccessCoverage, AccessCoverageStatus, AccessDecision,
9-
AccessEnforcement, AccessFinding, AccessOperation, AccessSeverity, AccessSourceKind,
9+
AccessEnforcement, AccessFinding, AccessOperation, AccessRuleMechanism, AccessSeverity,
10+
AccessSourceKind,
1011
};
1112
use serde_json::Value;
1213

1314
use super::{
1415
CollectedAccess,
1516
configuration::{capability, default_capability},
1617
history_scan::{HistoryAdapter, RuntimeCollector, permission_mode, runtime_capability},
17-
host_pattern, plural_suffix, safe_command_identifier, source,
18+
host_pattern, network_rule_ref, plural_suffix, safe_command_identifier, source,
1819
};
1920

2021
pub(super) fn history_adapter(home: &Path) -> HistoryAdapter {
@@ -52,29 +53,41 @@ pub(super) fn inspect_configuration(home: &Path) -> CollectedAccess {
5253
let state_path = home.join(".claude.json");
5354
let project_roots = inspect_state(&state_path, &mut collected);
5455
let mut settings = BTreeSet::new();
55-
settings.insert((config_dir(home).join("settings.json"), None));
56-
settings.extend(managed_settings().into_iter().map(|path| (path, None)));
56+
settings.insert((config_dir(home).join("settings.json"), None, true));
57+
settings.extend(
58+
managed_settings()
59+
.into_iter()
60+
.map(|path| (path, None, false)),
61+
);
5762
for workspace in project_roots {
5863
settings.insert((
5964
workspace.join(".claude/settings.json"),
6065
Some(workspace.clone()),
66+
true,
6167
));
6268
settings.insert((
6369
workspace.join(".claude/settings.local.json"),
6470
Some(workspace),
71+
true,
6572
));
6673
}
6774

6875
let mut inspected = usize::from(state_path.is_file());
69-
for (path, workspace) in settings {
76+
for (path, workspace, editable) in settings {
7077
let Ok(contents) = fs::read(&path) else {
7178
continue;
7279
};
7380
let Ok(document) = serde_json::from_slice::<Value>(&contents) else {
7481
continue;
7582
};
7683
inspected += 1;
77-
parse_settings(&document, &path, workspace.as_deref(), &mut collected);
84+
parse_settings(
85+
&document,
86+
&path,
87+
workspace.as_deref(),
88+
editable,
89+
&mut collected,
90+
);
7891
}
7992
collected.capabilities.extend([
8093
default_capability(
@@ -168,6 +181,7 @@ fn inspect_state(path: &Path, collected: &mut CollectedAccess) -> Vec<PathBuf> {
168181
enforcement: AccessEnforcement::Harness,
169182
workspace: Some(workspace.clone()),
170183
source: source.clone(),
184+
rule: None,
171185
detail: Some("Trusted Claude workspace".to_owned()),
172186
},
173187
AccessCapability {
@@ -178,6 +192,7 @@ fn inspect_state(path: &Path, collected: &mut CollectedAccess) -> Vec<PathBuf> {
178192
enforcement: AccessEnforcement::Harness,
179193
workspace: Some(workspace.clone()),
180194
source,
195+
rule: None,
181196
detail: Some(
182197
"Trusted workspace; edit gating depends on permission mode".to_owned(),
183198
),
@@ -188,8 +203,10 @@ fn inspect_state(path: &Path, collected: &mut CollectedAccess) -> Vec<PathBuf> {
188203
parse_rule(
189204
rule,
190205
AccessDecision::Allow,
206+
None,
191207
path,
192208
Some(&workspace),
209+
false,
193210
collected,
194211
);
195212
}
@@ -203,6 +220,7 @@ fn parse_settings(
203220
document: &Value,
204221
path: &Path,
205222
workspace: Option<&Path>,
223+
editable: bool,
206224
collected: &mut CollectedAccess,
207225
) {
208226
if let Some(permissions) = document.get("permissions").and_then(Value::as_object) {
@@ -213,7 +231,15 @@ fn parse_settings(
213231
] {
214232
if let Some(rules) = permissions.get(key).and_then(Value::as_array) {
215233
for rule in rules.iter().filter_map(Value::as_str) {
216-
parse_rule(rule, decision.clone(), path, workspace, collected);
234+
parse_rule(
235+
rule,
236+
decision.clone(),
237+
Some(key),
238+
path,
239+
workspace,
240+
editable,
241+
collected,
242+
);
217243
}
218244
}
219245
}
@@ -277,15 +303,17 @@ fn parse_settings(
277303
}
278304
}
279305
if let Some(sandbox) = document.get("sandbox").and_then(Value::as_object) {
280-
parse_sandbox(sandbox, path, workspace, collected);
306+
parse_sandbox(sandbox, path, workspace, editable, collected);
281307
}
282308
}
283309

284310
fn parse_rule(
285311
rule: &str,
286312
decision: AccessDecision,
313+
list: Option<&str>,
287314
path: &Path,
288315
workspace: Option<&Path>,
316+
editable: bool,
289317
collected: &mut CollectedAccess,
290318
) {
291319
let (tool, specifier) = rule
@@ -325,7 +353,7 @@ fn parse_rule(
325353
),
326354
_ => return,
327355
};
328-
collected.capabilities.push(capability(
356+
let mut capability = capability(
329357
category,
330358
&resource,
331359
operations,
@@ -334,13 +362,26 @@ fn parse_rule(
334362
path,
335363
workspace,
336364
"Claude permission rule",
337-
));
365+
);
366+
if editable
367+
&& tool == "WebFetch"
368+
&& let Some(list) = list
369+
&& rule == format!("WebFetch(domain:{resource})")
370+
{
371+
capability.rule = Some(network_rule_ref(
372+
AccessRuleMechanism::ClaudePermission,
373+
path,
374+
&format!("permissions.{list}\0{rule}"),
375+
));
376+
}
377+
collected.capabilities.push(capability);
338378
}
339379

340380
fn parse_sandbox(
341381
sandbox: &serde_json::Map<String, Value>,
342382
path: &Path,
343383
workspace: Option<&Path>,
384+
editable: bool,
344385
collected: &mut CollectedAccess,
345386
) {
346387
if sandbox.get("enabled").and_then(Value::as_bool) == Some(true) {
@@ -414,7 +455,7 @@ fn parse_sandbox(
414455
("deniedDomains", AccessDecision::Deny),
415456
] {
416457
for resource in string_array(network.get(key)) {
417-
collected.capabilities.push(capability(
458+
let mut capability = capability(
418459
AccessCategory::Network,
419460
resource,
420461
vec![AccessOperation::Connect],
@@ -423,7 +464,15 @@ fn parse_sandbox(
423464
path,
424465
workspace,
425466
"Claude sandbox network rule",
426-
));
467+
);
468+
if editable && host_pattern(resource).as_deref() == Some(resource) {
469+
capability.rule = Some(network_rule_ref(
470+
AccessRuleMechanism::ClaudeSandboxDomain,
471+
path,
472+
&format!("sandbox.network.{key}\0{resource}"),
473+
));
474+
}
475+
collected.capabilities.push(capability);
427476
}
428477
}
429478
}
@@ -473,7 +522,7 @@ mod tests {
473522
use std::path::{Path, PathBuf};
474523

475524
use agentdesktop_core::model::{
476-
AccessCategory, AccessDecision, AccessEnforcement, AccessSourceKind,
525+
AccessCategory, AccessDecision, AccessEnforcement, AccessRuleMechanism, AccessSourceKind,
477526
};
478527
use serde_json::json;
479528

@@ -497,6 +546,7 @@ mod tests {
497546
}),
498547
Path::new("settings.json"),
499548
Some(Path::new("/workspace")),
549+
true,
500550
&mut collected,
501551
);
502552

@@ -511,6 +561,20 @@ mod tests {
511561
capability.enforcement == AccessEnforcement::Sandbox
512562
&& capability.resource == "docs.example.com"
513563
}));
564+
let editable_network_rules: Vec<_> = collected
565+
.capabilities
566+
.iter()
567+
.filter(|capability| capability.resource == "docs.example.com")
568+
.filter_map(|capability| capability.rule.as_ref())
569+
.collect();
570+
assert_eq!(editable_network_rules.len(), 2);
571+
assert!(editable_network_rules.iter().any(|rule| {
572+
rule.mechanism == AccessRuleMechanism::ClaudePermission && rule.id.len() == 64
573+
}));
574+
assert!(editable_network_rules.iter().any(|rule| {
575+
rule.mechanism == AccessRuleMechanism::ClaudeSandboxDomain && rule.id.len() == 64
576+
}));
577+
assert_ne!(editable_network_rules[0].id, editable_network_rules[1].id);
514578
assert!(!format!("{:?}", collected.capabilities).contains("super-secret"));
515579
}
516580

‎crates/agent/src/access/configuration.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub(super) fn capability(
4747
enforcement,
4848
workspace: workspace.map(Path::to_path_buf),
4949
source: source(AccessSourceKind::Configuration, Some(path.to_path_buf())),
50+
rule: None,
5051
detail: Some(detail.to_owned()),
5152
}
5253
}
@@ -67,6 +68,7 @@ pub(super) fn default_capability(
6768
enforcement,
6869
workspace: None,
6970
source: source(AccessSourceKind::Default, None),
71+
rule: None,
7072
detail: Some(detail.to_owned()),
7173
}
7274
}

0 commit comments

Comments
 (0)