Skip to content

Commit 066a786

Browse files
alexng353claude
andcommitted
[agent] feat: per-project caps on variable count and plaintext size
Two new settings, enforced by `envx set` before upload: settings.max_variables_per_project (default 256) settings.max_project_bytes (default 100 * 1024 * 1024) Pre-flight check uses the variable list already fetched for the overwrite-detection step (no extra network call). Computes the final state after the upload would land — `existing - overwrites + new` for both count and plaintext bytes — and bails with a clear message if either cap would be breached, naming the exact `envx config set` command to raise the limit. Bytes are plaintext (key + value), not ciphertext. The user-facing mental model is "how much did I type", which matches what they can control. Ciphertext tracks plaintext closely (PGP adds ~constant per-recipient overhead) so the cap still bounds server-side storage. **Caveat for abuse prevention:** these are client-side caps. A modified CLI can bypass them. For real abuse defense, the server needs to enforce the same limits. New FieldKind::Number variant in the field registry handles u32/u64 parsing for the two new settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2cf895 commit 066a786

4 files changed

Lines changed: 141 additions & 3 deletions

File tree

‎src/commands/config/fields.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub enum FieldKind {
88
Password,
99
StringList,
1010
KeyringExpiry,
11+
Number,
1112
}
1213

1314
pub struct Field {
@@ -61,6 +62,20 @@ pub const FIELDS: &[Field] = &[
6162
format_hint: "true | false",
6263
can_unset: true,
6364
},
65+
Field {
66+
path: "settings.max_variables_per_project",
67+
kind: FieldKind::Number,
68+
description: "Reject `envx set` that would push a project over this many variables",
69+
format_hint: "positive integer (default 256)",
70+
can_unset: true,
71+
},
72+
Field {
73+
path: "settings.max_project_bytes",
74+
kind: FieldKind::Number,
75+
description: "Reject `envx set` that would push a project over this many bytes of plaintext",
76+
format_hint: "positive integer in bytes (default 104857600 = 100MB)",
77+
can_unset: true,
78+
},
6479
];
6580

6681
pub fn find(path: &str) -> Result<&'static Field> {
@@ -126,6 +141,18 @@ pub fn current_display(config: &Config, field: &Field) -> String {
126141
.and_then(|s| s.loud)
127142
.map(|b| b.to_string())
128143
.unwrap_or_else(|| "false".to_string()),
144+
"settings.max_variables_per_project" => config
145+
.settings
146+
.as_ref()
147+
.and_then(|s| s.max_variables_per_project)
148+
.map(|n| n.to_string())
149+
.unwrap_or_else(|| "<unset>".to_string()),
150+
"settings.max_project_bytes" => config
151+
.settings
152+
.as_ref()
153+
.and_then(|s| s.max_project_bytes)
154+
.map(|n| n.to_string())
155+
.unwrap_or_else(|| "<unset>".to_string()),
129156
_ => "?".to_string(),
130157
}
131158
}
@@ -157,6 +184,17 @@ pub fn current_json(config: &Config, field: &Field) -> serde_json::Value {
157184
serde_json::to_value(config.settings.as_ref().and_then(|s| s.loud))
158185
.unwrap()
159186
}
187+
"settings.max_variables_per_project" => serde_json::to_value(
188+
config
189+
.settings
190+
.as_ref()
191+
.and_then(|s| s.max_variables_per_project),
192+
)
193+
.unwrap(),
194+
"settings.max_project_bytes" => serde_json::to_value(
195+
config.settings.as_ref().and_then(|s| s.max_project_bytes),
196+
)
197+
.unwrap(),
160198
_ => serde_json::Value::Null,
161199
}
162200
}
@@ -202,6 +240,22 @@ pub fn set(config: &mut Config, field: &Field, raw: &str) -> Result<()> {
202240
s.loud = Some(v);
203241
config.settings = Some(s);
204242
}
243+
"settings.max_variables_per_project" => {
244+
let v: u32 = raw.trim().parse().map_err(|_| {
245+
anyhow!("Expected positive integer, got '{}'", raw.trim())
246+
})?;
247+
let mut s = config.settings.clone().unwrap_or_default();
248+
s.max_variables_per_project = Some(v);
249+
config.settings = Some(s);
250+
}
251+
"settings.max_project_bytes" => {
252+
let v: u64 = raw.trim().parse().map_err(|_| {
253+
anyhow!("Expected positive integer, got '{}'", raw.trim())
254+
})?;
255+
let mut s = config.settings.clone().unwrap_or_default();
256+
s.max_project_bytes = Some(v);
257+
config.settings = Some(s);
258+
}
205259
_ => bail!("Field not handled in setter"),
206260
}
207261
Ok(())
@@ -229,6 +283,16 @@ pub fn unset(config: &mut Config, field: &Field) -> Result<()> {
229283
s.loud = None;
230284
}
231285
}
286+
"settings.max_variables_per_project" => {
287+
if let Some(s) = config.settings.as_mut() {
288+
s.max_variables_per_project = None;
289+
}
290+
}
291+
"settings.max_project_bytes" => {
292+
if let Some(s) = config.settings.as_mut() {
293+
s.max_project_bytes = None;
294+
}
295+
}
232296
_ => bail!("Field not handled in unsetter"),
233297
}
234298
Ok(())

‎src/commands/config/set.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,9 @@ fn read_value(field: &Field, config: &Config) -> Result<String> {
103103
let v = prompt_confirm_with_default(&prompt, current)?;
104104
Ok(v.to_string())
105105
}
106-
FieldKind::Url | FieldKind::StringList | FieldKind::KeyringExpiry => {
107-
prompt_text(&prompt)
108-
}
106+
FieldKind::Url
107+
| FieldKind::StringList
108+
| FieldKind::KeyringExpiry
109+
| FieldKind::Number => prompt_text(&prompt),
109110
}
110111
}

‎src/commands/set.rs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::{
1111
kvpair::KVPair,
1212
// partial_variable::ToParsed,
1313
prompt::prompt_confirm,
14+
variable::DecryptedVariable,
1415
},
1516
};
1617

@@ -110,6 +111,8 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
110111
})
111112
.collect::<Vec<_>>();
112113

114+
enforce_project_caps(config, &variables, &existing_keys, &kvpairs)?;
115+
113116
if !existing_keys.is_empty() {
114117
println!("The following variables already exist:");
115118
for key in &existing_keys {
@@ -157,3 +160,57 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
157160

158161
Ok(())
159162
}
163+
164+
fn kvpair_bytes(key: &str, value: &str) -> u64 {
165+
(key.len() + value.len()) as u64
166+
}
167+
168+
fn enforce_project_caps(
169+
config: &Config,
170+
existing: &[DecryptedVariable],
171+
overwrites: &[&DecryptedVariable],
172+
incoming: &[KVPair],
173+
) -> Result<()> {
174+
let settings = config.get_settings();
175+
let max_count = settings.get_max_variables_per_project() as usize;
176+
let max_bytes = settings.get_max_project_bytes();
177+
178+
let final_count = existing
179+
.len()
180+
.saturating_sub(overwrites.len())
181+
.saturating_add(incoming.len());
182+
if final_count > max_count {
183+
bail!(
184+
"Variable count cap exceeded: this `envx set` would put the project at {} variable(s) (cap: {}).\n\
185+
Raise it with `envx config set settings.max_variables_per_project <n>`.",
186+
final_count,
187+
max_count,
188+
);
189+
}
190+
191+
let existing_bytes: u64 = existing
192+
.iter()
193+
.map(|v| kvpair_bytes(&v.value.key, &v.value.value))
194+
.sum();
195+
let overwrite_bytes: u64 = overwrites
196+
.iter()
197+
.map(|v| kvpair_bytes(&v.value.key, &v.value.value))
198+
.sum();
199+
let new_bytes: u64 = incoming
200+
.iter()
201+
.map(|kv| kvpair_bytes(&kv.key, &kv.value))
202+
.sum();
203+
let final_bytes = existing_bytes
204+
.saturating_sub(overwrite_bytes)
205+
.saturating_add(new_bytes);
206+
if final_bytes > max_bytes {
207+
bail!(
208+
"Project size cap exceeded: this `envx set` would put the project at {} bytes of plaintext (cap: {}).\n\
209+
Raise it with `envx config set settings.max_project_bytes <bytes>`.",
210+
final_bytes,
211+
max_bytes,
212+
);
213+
}
214+
215+
Ok(())
216+
}

‎src/utils/settings.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ pub enum KeyringExpiry {
66
Days(u32),
77
}
88

9+
pub const DEFAULT_MAX_VARIABLES_PER_PROJECT: u32 = 256;
10+
pub const DEFAULT_MAX_PROJECT_BYTES: u64 = 100 * 1024 * 1024;
11+
912
#[derive(Debug, Serialize, Deserialize, Clone)]
1013
pub struct Settings {
1114
pub warn_on_short_passwords: bool,
1215
pub keyring_expiry: Option<KeyringExpiry>,
1316
pub loud: Option<bool>,
17+
pub max_variables_per_project: Option<u32>,
18+
pub max_project_bytes: Option<u64>,
1419
}
1520

1621
impl Default for Settings {
@@ -19,6 +24,8 @@ impl Default for Settings {
1924
warn_on_short_passwords: false,
2025
keyring_expiry: Some(KeyringExpiry::Days(30)),
2126
loud: None,
27+
max_variables_per_project: None,
28+
max_project_bytes: None,
2229
}
2330
}
2431
}
@@ -32,4 +39,13 @@ impl Settings {
3239
pub fn is_loud(&self) -> bool {
3340
self.loud.unwrap_or(false)
3441
}
42+
43+
pub fn get_max_variables_per_project(&self) -> u32 {
44+
self.max_variables_per_project
45+
.unwrap_or(DEFAULT_MAX_VARIABLES_PER_PROJECT)
46+
}
47+
48+
pub fn get_max_project_bytes(&self) -> u64 {
49+
self.max_project_bytes.unwrap_or(DEFAULT_MAX_PROJECT_BYTES)
50+
}
3551
}

0 commit comments

Comments
 (0)