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
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.

217 changes: 193 additions & 24 deletions apps/desktop/src-tauri/src/lib.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions apps/desktop/src/data/workspace.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
{
"apiVersion": "0.1",
"source": "synthetic_fixture",
"captureProfile": {
"version": "0.1",
"name": "OpenAI default",
"gatewayUpstream": "https://api.openai.com",
"additionalHosts": []
},
"capture": {
"active": true,
"canControl": false,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/generated/desktop-api/CaptureProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type CaptureProfile = { version: string, name: string, gatewayUpstream: string, additionalHosts: Array<string>, };
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CaptureCompatibility } from "./CaptureCompatibility";
import type { CaptureProfile } from "./CaptureProfile";
import type { CaptureState } from "./CaptureState";
import type { CapturedRequest } from "./CapturedRequest";
import type { WorkspaceSource } from "./WorkspaceSource";

export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, capture: CaptureState, compatibility: CaptureCompatibility, requests: Array<CapturedRequest>, };
export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, captureProfile: CaptureProfile, capture: CaptureState, compatibility: CaptureCompatibility, requests: Array<CapturedRequest>, };
1 change: 1 addition & 0 deletions apps/desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type { CaptureCompatibility } from "./generated/desktop-api/CaptureCompat
export type { CaptureCompatibilityCode } from "./generated/desktop-api/CaptureCompatibilityCode";
export type { CaptureCompatibilityStatus } from "./generated/desktop-api/CaptureCompatibilityStatus";
export type { CaptureMode } from "./generated/desktop-api/CaptureMode";
export type { CaptureProfile } from "./generated/desktop-api/CaptureProfile";
export type { CaptureState } from "./generated/desktop-api/CaptureState";
export type { CaptureStatus } from "./generated/desktop-api/CaptureStatus";
export type { CertificateAuthority } from "./generated/desktop-api/CertificateAuthority";
Expand Down
3 changes: 2 additions & 1 deletion crates/desktop-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ description = "Versioned desktop command DTOs for CodeIsCheap"

[dependencies]
codeischeap-capture-ipc = { path = "../capture-ipc" }
codeischeap-capture-policy = { path = "../capture-policy" }
codeischeap-prompt-ir = { path = "../prompt-ir" }
codeischeap-storage = { path = "../storage" }
regex.workspace = true
Expand All @@ -15,7 +16,7 @@ serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
ts-rs.workspace = true
url.workspace = true

[dev-dependencies]
codeischeap-capture-policy = { path = "../capture-policy" }
tempfile.workspace = true
5 changes: 3 additions & 2 deletions crates/desktop-api/src/bin/export-desktop-contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use std::fs;
use std::path::PathBuf;

use codeischeap_desktop_api::{
BetaMetricsPreview, BetaMetricsSnapshot, ExportPreview, ExportProfile, ExportReceipt,
ExportRedaction, SupportBundlePreview, UpdateStatus, WorkspaceBootstrap,
BetaMetricsPreview, BetaMetricsSnapshot, CaptureProfile, ExportPreview, ExportProfile,
ExportReceipt, ExportRedaction, SupportBundlePreview, UpdateStatus, WorkspaceBootstrap,
};
use ts_rs::{Config, TS};

Expand All @@ -23,6 +23,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_out_dir(bindings_path)
.with_large_int("number");
WorkspaceBootstrap::export_all(&config)?;
CaptureProfile::export_all(&config)?;
ExportProfile::export_all(&config)?;
ExportRedaction::export_all(&config)?;
ExportPreview::export_all(&config)?;
Expand Down
1 change: 1 addition & 0 deletions crates/desktop-api/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ mod tests {
let workspace = WorkspaceBootstrap {
api_version: DESKTOP_API_VERSION.to_owned(),
source: WorkspaceSource::EncryptedLocal,
capture_profile: crate::CaptureProfile::default(),
compatibility: diagnose_capture_compatibility(&capture, 0),
capture,
requests: vec![request()],
Expand Down
7 changes: 7 additions & 0 deletions crates/desktop-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod beta;
mod compatibility;
mod export;
mod profile;
mod update;

pub use beta::{
Expand All @@ -14,6 +15,10 @@ pub use compatibility::{
CompatibilityAction, CompatibilityConfidence, CompatibilityStep, CompatibilityStepStatus,
diagnose_capture_compatibility, recovery_read_only_compatibility,
};
pub use profile::{
CAPTURE_PROFILE_VERSION, CaptureProfile, CaptureProfileError, DEFAULT_CAPTURE_PROFILE_NAME,
DEFAULT_GATEWAY_UPSTREAM, MAX_CAPTURE_PROFILE_NAME_BYTES,
};

pub use export::{
DiagnosticEvent, EXPORT_FORMAT_VERSION, EXPORT_POLICY_VERSION, ExportPreview, ExportProfile,
Expand Down Expand Up @@ -45,6 +50,7 @@ pub const DESKTOP_API_VERSION: &str = "0.1";
pub struct WorkspaceBootstrap {
pub api_version: String,
pub source: WorkspaceSource,
pub capture_profile: CaptureProfile,
pub capture: CaptureState,
pub compatibility: CaptureCompatibility,
pub requests: Vec<CapturedRequest>,
Expand Down Expand Up @@ -314,6 +320,7 @@ pub fn load_workspace(store: &EncryptedStore) -> Result<WorkspaceBootstrap, Desk
Ok(WorkspaceBootstrap {
api_version: DESKTOP_API_VERSION.to_owned(),
source: WorkspaceSource::EncryptedLocal,
capture_profile: CaptureProfile::default(),
capture,
compatibility,
requests,
Expand Down
269 changes: 269 additions & 0 deletions crates/desktop-api/src/profile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
use std::collections::HashSet;
use std::fmt;

use codeischeap_capture_ipc::{CapturedBody, CapturedBodyState, CapturedRequest};
use codeischeap_capture_policy::{CapturePolicy, PolicyError, normalize_additional_hosts};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use url::Url;

pub const CAPTURE_PROFILE_VERSION: &str = "0.1";
pub const DEFAULT_GATEWAY_UPSTREAM: &str = "https://api.openai.com";
pub const DEFAULT_CAPTURE_PROFILE_NAME: &str = "OpenAI default";
pub const MAX_CAPTURE_PROFILE_NAME_BYTES: usize = 64;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CaptureProfile {
pub version: String,
pub name: String,
pub gateway_upstream: String,
pub additional_hosts: Vec<String>,
}

impl Default for CaptureProfile {
fn default() -> Self {
Self {
version: CAPTURE_PROFILE_VERSION.to_owned(),
name: DEFAULT_CAPTURE_PROFILE_NAME.to_owned(),
gateway_upstream: DEFAULT_GATEWAY_UPSTREAM.to_owned(),
additional_hosts: Vec::new(),
}
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureProfileError {
InvalidJson,
UnsupportedVersion(String),
InvalidName,
InvalidGatewayUpstream,
GatewayCredentialsForbidden,
GatewayOriginRequired,
BuiltInAdditionalHost(String),
Policy(PolicyError),
}

impl fmt::Display for CaptureProfileError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidJson => write!(formatter, "capture profile JSON is invalid"),
Self::UnsupportedVersion(version) => {
write!(
formatter,
"capture profile version {version} is unsupported"
)
}
Self::InvalidName => write!(
formatter,
"capture profile name must be trimmed text of at most {MAX_CAPTURE_PROFILE_NAME_BYTES} bytes"
),
Self::InvalidGatewayUpstream => write!(
formatter,
"Gateway upstream must be an absolute HTTP or HTTPS URL"
),
Self::GatewayCredentialsForbidden => write!(
formatter,
"Gateway upstream must not contain embedded credentials"
),
Self::GatewayOriginRequired => write!(
formatter,
"Gateway upstream must be an origin without a path, query, or fragment"
),
Self::BuiltInAdditionalHost(host) => {
write!(
formatter,
"additional capture host {host} is already built in"
)
}
Self::Policy(error) => write!(formatter, "{error}"),
}
}
}

impl std::error::Error for CaptureProfileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Policy(error) => Some(error),
_ => None,
}
}
}

impl From<PolicyError> for CaptureProfileError {
fn from(error: PolicyError) -> Self {
Self::Policy(error)
}
}

impl CaptureProfile {
pub fn from_json(encoded: &str) -> Result<Self, CaptureProfileError> {
let profile: Self =
serde_json::from_str(encoded).map_err(|_| CaptureProfileError::InvalidJson)?;
profile.validated()
}

pub fn validated(mut self) -> Result<Self, CaptureProfileError> {
if self.version != CAPTURE_PROFILE_VERSION {
return Err(CaptureProfileError::UnsupportedVersion(self.version));
}
let name = self.name.trim();
if name.is_empty()
|| name.len() > MAX_CAPTURE_PROFILE_NAME_BYTES
|| name.chars().any(char::is_control)
{
return Err(CaptureProfileError::InvalidName);
}
self.name = name.to_owned();

let upstream = validate_gateway_upstream(&self.gateway_upstream)?;
self.gateway_upstream = canonical_origin(&upstream);
self.additional_hosts = normalize_additional_hosts(&self.additional_hosts)?;
let built_in_hosts = built_in_hosts()?;
if let Some(host) = self
.additional_hosts
.iter()
.find(|host| built_in_hosts.contains(host.as_str()))
{
return Err(CaptureProfileError::BuiltInAdditionalHost(host.clone()));
}
self.capture_policy()?;
Ok(self)
}

pub fn gateway_url(&self) -> Result<Url, CaptureProfileError> {
validate_gateway_upstream(&self.gateway_upstream)
}

pub fn capture_policy(&self) -> Result<CapturePolicy, CaptureProfileError> {
let mut scope_hosts = self.additional_hosts.clone();
let upstream = self.gateway_url()?;
let upstream_host = upstream
.host_str()
.ok_or(CaptureProfileError::InvalidGatewayUpstream)?
.to_ascii_lowercase();
let default_policy = CapturePolicy::load_default()?;
let upstream_has_openai_scope = default_policy
.matching_target(&openai_scope_probe(&upstream_host))
.is_some();
if !upstream_has_openai_scope && !scope_hosts.contains(&upstream_host) {
scope_hosts.push(upstream_host);
}
Ok(default_policy.with_additional_hosts(&scope_hosts)?)
}
}

fn validate_gateway_upstream(value: &str) -> Result<Url, CaptureProfileError> {
let upstream = Url::parse(value).map_err(|_| CaptureProfileError::InvalidGatewayUpstream)?;
if !matches!(upstream.scheme(), "http" | "https") || upstream.host_str().is_none() {
return Err(CaptureProfileError::InvalidGatewayUpstream);
}
if !upstream.username().is_empty() || upstream.password().is_some() {
return Err(CaptureProfileError::GatewayCredentialsForbidden);
}
if upstream.path() != "/" || upstream.query().is_some() || upstream.fragment().is_some() {
return Err(CaptureProfileError::GatewayOriginRequired);
}
Ok(upstream)
}

fn canonical_origin(upstream: &Url) -> String {
let mut origin = upstream.origin().ascii_serialization();
if origin.ends_with('/') {
origin.pop();
}
origin
}

fn built_in_hosts() -> Result<HashSet<String>, CaptureProfileError> {
Ok(CapturePolicy::load_default()?
.targets
.into_iter()
.flat_map(|target| target.hosts)
.collect())
}

fn openai_scope_probe(host: &str) -> CapturedRequest {
CapturedRequest {
method: "POST".to_owned(),
scheme: "https".to_owned(),
host: host.to_owned(),
port: 443,
path: "/v1/chat/completions".to_owned(),
query: Vec::new(),
headers: Vec::new(),
body: CapturedBody {
state: CapturedBodyState::Empty,
content: None,
},
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_profile_uses_the_built_in_openai_scope() {
let profile = CaptureProfile::default()
.validated()
.expect("default profile");
assert_eq!(profile.gateway_upstream, DEFAULT_GATEWAY_UPSTREAM);
assert_eq!(profile.capture_policy().expect("policy").targets.len(), 5);
}

#[test]
fn custom_origins_and_hosts_are_canonical_and_bounded_by_existing_paths() {
let profile = CaptureProfile {
version: CAPTURE_PROFILE_VERSION.to_owned(),
name: " Private lab ".to_owned(),
gateway_upstream: "https://LOCALHOST:8443/".to_owned(),
additional_hosts: vec![" Proxy.EXAMPLE.test. ".to_owned()],
}
.validated()
.expect("custom profile");
assert_eq!(profile.name, "Private lab");
assert_eq!(profile.gateway_upstream, "https://localhost:8443");
assert_eq!(profile.additional_hosts, ["proxy.example.test"]);

let policy = profile.capture_policy().expect("custom policy");
assert!(
policy
.matching_target(&openai_scope_probe("localhost"))
.is_some()
);
let mut denied = openai_scope_probe("proxy.example.test");
denied.path = "/admin".to_owned();
assert!(policy.matching_target(&denied).is_none());
}

#[test]
fn profiles_reject_credentials_non_origins_and_built_in_scope_expansion() {
for upstream in [
"ftp://example.test",
"https://user:secret@example.test",
"https://example.test/v1",
"https://example.test?key=value",
] {
assert!(
CaptureProfile {
gateway_upstream: upstream.to_owned(),
..CaptureProfile::default()
}
.validated()
.is_err()
);
}
assert_eq!(
CaptureProfile {
additional_hosts: vec!["api.openai.com".to_owned()],
..CaptureProfile::default()
}
.validated(),
Err(CaptureProfileError::BuiltInAdditionalHost(
"api.openai.com".to_owned()
))
);
}
}
Loading
Loading