Skip to content
Open
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
95 changes: 66 additions & 29 deletions crates/seal-tui/src/toml_editor/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,36 @@ pub(super) fn upsert_allow_command(
) -> Result<()> {
let patterns = ensure_patterns_array(doc, "allow")?;

// Scan for an existing match. The entry can be either a bare
// string or an inline table with `command = "<pat>"`.
for i in 0..patterns.len() {
let Some(value) = patterns.get(i) else {
continue;
};
match extract_command(value) {
Some(existing) if existing == pattern => {
// Replace with the requested form.
if prompt {
// Write an inline table to preserve the prompt flag.
let mut it = InlineTable::new();
it.insert("command", Value::from(pattern));
it.insert("prompt", Value::from(true));
patterns.replace(i, Value::InlineTable(it));
} else {
// Bare string form — equivalent to `prompt = false`.
patterns.replace(i, Value::from(pattern));
}
if prompt {
// Prompted allows are only written by callers that explicitly
// want that shape. Preserve the old first-match semantics here;
// Allow-once uses `upsert_allow_command_prompt_only` to avoid
// downgrading silent grants.
for i in 0..patterns.len() {
let Some(value) = patterns.get(i) else {
continue;
};
if extract_command(value).is_some_and(|existing| existing == pattern) {
let mut it = InlineTable::new();
it.insert("command", Value::from(pattern));
it.insert("prompt", Value::from(true));
patterns.replace(i, Value::InlineTable(it));
return Ok(());
}
_ => {}
}
}

// Not found — append.
if prompt {
let mut it = InlineTable::new();
it.insert("command", Value::from(pattern));
it.insert("prompt", Value::from(true));
push_preserving_format(patterns, Value::InlineTable(it));
} else {
push_preserving_format(patterns, Value::from(pattern));
return Ok(());
}

if normalize_allow_command_prompt_false(patterns, pattern) {
return Ok(());
}

push_preserving_format(patterns, Value::from(pattern));
Ok(())
}

Expand Down Expand Up @@ -224,16 +220,57 @@ fn ensure_command_domains_array<'a>(doc: &'a mut DocumentMut, side: &str) -> Res
/// into a bare string) without adding a new one.
/// Returns `true` if an entry was found and modified, `false` otherwise.
pub(super) fn flip_prompt_in_array(patterns: &mut Array, pattern: &str) -> Result<bool> {
for i in 0..patterns.len() {
Ok(normalize_allow_command_prompt_false(patterns, pattern))
}

fn normalize_allow_command_prompt_false(patterns: &mut Array, pattern: &str) -> bool {
let mut found = false;
let mut kept_simple = false;
let mut i = 0;
while i < patterns.len() {
let Some(value) = patterns.get(i) else {
i += 1;
continue;
};
if extract_command(value).is_some_and(|c| c == pattern) {
if extract_command(value).is_none_or(|c| c != pattern) {
i += 1;
continue;
}

found = true;
if command_entry_has_extra_policy(value) {

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Duplicate entries with extra policy keys (e.g., domains) are not deduplicated. If the array contains two identical entries like { command = "curl:*", domains = ["x.com"], prompt = true }, both will have prompt removed but both remain in the output — producing two identical { command = "curl:*", domains = ["x.com"] } entries. This differs from normalize_allow_fs_prompt_false which removes all entries after the first unconditionally. Consider tracking whether an extra-policy entry was already kept and removing subsequent identical ones.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/seal-tui/src/toml_editor/commands.rs, line 241:

<comment>Duplicate entries with extra policy keys (e.g., `domains`) are not deduplicated. If the array contains two identical entries like `{ command = "curl:*", domains = ["x.com"], prompt = true }`, both will have `prompt` removed but both remain in the output — producing two identical `{ command = "curl:*", domains = ["x.com"] }` entries. This differs from `normalize_allow_fs_prompt_false` which removes all entries after the first unconditionally. Consider tracking whether an extra-policy entry was already kept and removing subsequent identical ones.</comment>

<file context>
@@ -224,16 +220,57 @@ fn ensure_command_domains_array<'a>(doc: &'a mut DocumentMut, side: &str) -> Res
+        }
+
+        found = true;
+        if command_entry_has_extra_policy(value) {
+            let replacement = command_entry_without_prompt(value);
+            patterns.replace(i, replacement);
</file context>
Fix with cubic

let replacement = command_entry_without_prompt(value);
patterns.replace(i, replacement);
i += 1;
} else if kept_simple {
patterns.remove(i);
} else {
patterns.replace(i, Value::from(pattern));
return Ok(true);
kept_simple = true;
i += 1;
}
Comment on lines +241 to +251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Extra-policy duplicates are not deduplicated

normalize_allow_command_prompt_false correctly collapses duplicate simple entries (only command/prompt keys) by tracking kept_simple and removing extras, but the command_entry_has_extra_policy branch replaces every matching entry individually without any deduplication guard. If the array contains two identical entries such as { command = "curl:*", domains = ["x.com"], prompt = true } twice, both have their prompt key removed but both remain — producing [{ command = "curl:*", domains = ["x.com"] }, { command = "curl:*", domains = ["x.com"] }].

This differs from normalize_allow_fs_prompt_false, which removes all entries after the first unconditionally. A test covering flip_prompt_false on duplicate identical extra-policy entries would make the intended behaviour explicit.

}
found
}

fn command_entry_has_extra_policy(value: &Value) -> bool {
match value {
Value::InlineTable(table) => table
.iter()
.any(|(key, _)| key != "command" && key != "prompt"),
_ => false,
}
}

fn command_entry_without_prompt(value: &Value) -> Value {
match value {
Value::InlineTable(table) => {
let mut table = table.clone();
table.remove("prompt");
Value::InlineTable(table)
}
_ => value.clone(),
}
Ok(false)
}

/// Remove any allow-commands entry with `command == pattern`.
Expand Down
80 changes: 49 additions & 31 deletions crates/seal-tui/src/toml_editor/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub(super) fn upsert_allow_fs_path(
let defaults = section_default_files(doc, "allow", kind);
let paths = ensure_fs_paths_array(doc, "allow", kind)?;

if !prompt && normalize_allow_fs_prompt_false(paths, pattern, &defaults) {

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The (true, false) arm in the for loop is unreachable when prompt = false. normalize_allow_fs_prompt_false is called just above with the same array and matching logic (fs_entry_path_matches). If it returns true, the function already returned. If it returns false, no entry matched — so this loop also cannot find a match via fs_entry_path_matches, making the (true, false) branch dead code on the !prompt path. Consider guarding this loop with if prompt or removing the unreachable arm to clarify intent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/seal-tui/src/toml_editor/fs.rs, line 27:

<comment>The `(true, false)` arm in the `for` loop is unreachable when `prompt = false`. `normalize_allow_fs_prompt_false` is called just above with the same array and matching logic (`fs_entry_path_matches`). If it returns `true`, the function already returned. If it returns `false`, no entry matched — so this loop also cannot find a match via `fs_entry_path_matches`, making the `(true, false)` branch dead code on the `!prompt` path. Consider guarding this loop with `if prompt` or removing the unreachable arm to clarify intent.</comment>

<file context>
@@ -24,6 +24,10 @@ pub(super) fn upsert_allow_fs_path(
     let defaults = section_default_files(doc, "allow", kind);
     let paths = ensure_fs_paths_array(doc, "allow", kind)?;
 
+    if !prompt && normalize_allow_fs_prompt_false(paths, pattern, &defaults) {
+        return Ok(());
+    }
</file context>
Fix with cubic

return Ok(());
}

Comment on lines +27 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the no-default-files fallback when stripping prompt.

For matched shorthand entries, fs_entry_without_prompt now writes a bare string. In sections without default_files, that bypasses the existing prompt=false builder path that emits the explicit files = ["*"] fallback, so add_allow_fs_path(..., false) and flip_allow_fs_prompt_false can serialize a different rule than the normal prompt=false upsert.

Proposed fix
-            let replacement = fs_entry_without_prompt(value, pattern);
+            let replacement = fs_entry_without_prompt(value, pattern, section_default_files);
             paths.replace(i, replacement);
@@
-fn fs_entry_without_prompt(value: &Value, pattern: &str) -> Value {
+fn fs_entry_without_prompt(
+    value: &Value,
+    pattern: &str,
+    section_default_files: &[String],
+) -> Value {
     match value {
         Value::InlineTable(table) if table.contains_key("files") => {
             let mut table = table.clone();
             table.remove("prompt");
             Value::InlineTable(table)
         }
         _ => {
             let path = fs_entry_path(value).unwrap_or_else(|| pattern.to_string());
-            Value::from(path)
+            if section_default_files.is_empty() {
+                build_fs_entry_value(&path, false, false)
+            } else {
+                Value::from(path)
+            }
         }
     }
 }

Also applies to: 292-337

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/seal-tui/src/toml_editor/fs.rs` around lines 27 - 30, The
prompt-stripping path in fs_entry_without_prompt should preserve the same
no-default-files fallback as the prompt=false builder path. Update
normalize_allow_fs_prompt_false, add_allow_fs_path, and
flip_allow_fs_prompt_false so matched shorthand entries in sections without
default_files still serialize through the explicit files = ["*"] fallback
instead of becoming a bare string; keep the prompt=false upsert behavior
consistent with the normal path.

for i in 0..paths.len() {
let Some(value) = paths.get(i) else {
continue;
Expand All @@ -32,17 +36,10 @@ pub(super) fn upsert_allow_fs_path(
let existing_prompt = fs_entry_prompt(value).unwrap_or(false);
match (existing_prompt, prompt) {
(true, false) => {
// Flip prompt=true → prompt=false: emit the bare
// scope form, dropping the prompt flag.
paths.replace(i, build_fs_entry_value(pattern, false, has_defaults));
}
(false, true) => {
// Requested prompt=true but a stronger (no-prompt)
// entry exists. Never downgrade.
}
_ => {
// Same flag state — idempotent no-op.
}
(false, true) => {}
_ => {}
}
return Ok(());
Comment on lines 37 to 44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (true, false) arm is now unreachable when prompt = false

normalize_allow_fs_prompt_false is called just above with the same defaults slice and the same paths array; it scans every entry using fs_entry_path_matches. If it returns true (at least one match), the function has already returned early. If it returns false, no entry matched — so the subsequent for loop can also never find a match via fs_entry_path_matches, and the (true, false) arm here can never fire. The only effect of this loop on the prompt = false path is to fall through to push_preserving_format, which happens anyway. The (true, false) branch and the loop itself could be guarded by if prompt (or removed for the !prompt case) to make it clear this scan only matters when prompt = true.

}
Expand Down Expand Up @@ -292,33 +289,54 @@ pub(super) fn flip_fs_prompt_in_array(
pattern: &str,
section_default_files: &[String],
) -> Result<bool> {
for i in 0..paths.len() {
Ok(normalize_allow_fs_prompt_false(
paths,
pattern,
section_default_files,
))
}

fn normalize_allow_fs_prompt_false(
paths: &mut Array,
pattern: &str,
section_default_files: &[String],
) -> bool {
let mut found = false;
let mut i = 0;
while i < paths.len() {
let Some(value) = paths.get(i) else {
i += 1;
continue;
};
if fs_entry_path_matches(value, pattern, section_default_files) {
// Build a no-prompt replacement preserving the entry's
// current shape. For shorthand-with-flags (`{ path, prompt
// = true }`) this collapses back to a bare string; for
// `{ path, files, prompt = true }` it drops just the
// `prompt` key.
let new_val = match value {
Value::InlineTable(t) if t.contains_key("files") => {
let mut t = t.clone();
t.remove("prompt");
Value::InlineTable(t)
}
_ => {
// Collapse to shorthand.
let path = fs_entry_path(value).unwrap_or_else(|| pattern.to_string());
Value::from(path)
}
};
paths.replace(i, new_val);
return Ok(true);
if !fs_entry_path_matches(value, pattern, section_default_files) {
i += 1;
continue;
}

if found {
paths.remove(i);
} else {
let replacement = fs_entry_without_prompt(value, pattern);

@cubic-dev-ai cubic-dev-ai Bot Jun 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: fs_entry_without_prompt always collapses non-files entries to a bare string, but when section_default_files is empty, the normal upsert path emits { path = "...", files = ["*"] } to ensure the grant is effective. This means normalize_allow_fs_prompt_false can produce a different (potentially less permissive) rule than the standard prompt=false upsert when there are no default files configured for the section. Pass section_default_files to fs_entry_without_prompt and use build_fs_entry_value when defaults are empty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/seal-tui/src/toml_editor/fs.rs, line 319:

<comment>`fs_entry_without_prompt` always collapses non-`files` entries to a bare string, but when `section_default_files` is empty, the normal upsert path emits `{ path = "...", files = ["*"] }` to ensure the grant is effective. This means `normalize_allow_fs_prompt_false` can produce a different (potentially less permissive) rule than the standard `prompt=false` upsert when there are no default files configured for the section. Pass `section_default_files` to `fs_entry_without_prompt` and use `build_fs_entry_value` when defaults are empty.</comment>

<file context>
@@ -292,33 +289,54 @@ pub(super) fn flip_fs_prompt_in_array(
+        if found {
+            paths.remove(i);
+        } else {
+            let replacement = fs_entry_without_prompt(value, pattern);
+            paths.replace(i, replacement);
+            found = true;
</file context>
Fix with cubic

paths.replace(i, replacement);
found = true;
i += 1;
}
}
found
}

fn fs_entry_without_prompt(value: &Value, pattern: &str) -> Value {
match value {
Value::InlineTable(table) if table.contains_key("files") => {
let mut table = table.clone();
table.remove("prompt");
Value::InlineTable(table)
}
_ => {
let path = fs_entry_path(value).unwrap_or_else(|| pattern.to_string());
Value::from(path)
}
}
Ok(false)
}

pub(super) fn fs_paths_contain(
Expand Down
107 changes: 107 additions & 0 deletions crates/seal-tui/src/toml_editor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,58 @@ fn add_allow_is_idempotent_for_bare_string() {
assert_eq!(count, 1, "no duplicate; output:\n{out}");
}

#[test]
fn add_allow_removes_later_prompt_duplicate_when_silent_exists_first() {
let toml = r#"schema_version = 1
[model]
provider = "anthropic"
name = "claude-sonnet-4-6"

[capabilities.allow.commands]
patterns = ["jj log:*", { command = "jj log:*", prompt = true }, "pwd:*"]
"#;
let out = add_allow_command(toml, "jj log:*").unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""jj log:*""#).count(), 1, "{out}");
assert!(out.contains(r#""pwd:*""#), "{out}");
}

#[test]
fn flip_prompt_false_removes_later_prompt_duplicate_when_silent_exists_first() {
let toml = r#"schema_version = 1
[model]
provider = "anthropic"
name = "claude-sonnet-4-6"

[capabilities.allow.commands]
patterns = ["jj log:*", { command = "jj log:*", prompt = true }, "pwd:*"]
"#;
let out = flip_allow_prompt_false(toml, "jj log:*").unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""jj log:*""#).count(), 1, "{out}");
assert!(out.contains(r#""pwd:*""#), "{out}");
}

#[test]
fn flip_prompt_false_collapses_multiple_prompt_duplicates() {
let toml = r#"schema_version = 1
[model]
provider = "anthropic"
name = "claude-sonnet-4-6"

[capabilities.allow.commands]
patterns = [
{ command = "jj log:*", prompt = true },
{ command = "jj log:*", prompt = true },
]
"#;
let out = flip_allow_prompt_false(toml, "jj log:*").unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""jj log:*""#).count(), 1, "{out}");
}
#[test]
fn add_allow_removes_matching_deny() {
let toml = r#"schema_version = 1
Expand Down Expand Up @@ -705,6 +757,61 @@ paths = [{ path = "docs/**", prompt = true }]
assert!(!docs.prompt);
}

#[test]
fn add_allow_fs_path_removes_later_prompt_duplicate_when_silent_exists_first() {
let base = r#"schema_version = 1
[model]
provider = "anthropic"
name = "t"

[capabilities.allow.read]
default_files = ["*.rs"]
paths = ["src/**", { path = "src/**", prompt = true }, "docs/**"]
"#;
let out = add_allow_fs_path(base, FsKind::Read, "src/**", false).unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""src/**""#).count(), 1, "{out}");
assert!(out.contains(r#""docs/**""#), "{out}");
}

#[test]
fn flip_allow_fs_prompt_false_removes_later_prompt_duplicate_when_silent_exists_first() {
let base = r#"schema_version = 1
[model]
provider = "anthropic"
name = "t"

[capabilities.allow.read]
default_files = ["*.rs"]
paths = ["src/**", { path = "src/**", prompt = true }, "docs/**"]
"#;
let out = flip_allow_fs_prompt_false(base, FsKind::Read, "src/**").unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""src/**""#).count(), 1, "{out}");
assert!(out.contains(r#""docs/**""#), "{out}");
}

#[test]
fn flip_allow_fs_prompt_false_collapses_multiple_prompt_duplicates() {
let base = r#"schema_version = 1
[model]
provider = "anthropic"
name = "t"

[capabilities.allow.read]
default_files = ["*.rs"]
paths = [
{ path = "docs/**", prompt = true },
{ path = "docs/**", prompt = true },
]
"#;
let out = flip_allow_fs_prompt_false(base, FsKind::Read, "docs/**").unwrap();

assert!(!out.contains("prompt = true"), "{out}");
assert_eq!(out.matches(r#""docs/**""#).count(), 1, "{out}");
}
#[test]
fn add_allow_fs_path_accepts_inline_table_for_narrow_form() {
// The TUI's Tab-narrow produces `{ path = "docs", files = ["x.md"] }`.
Expand Down
Loading