Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
57864aa
Initial plan
Copilot May 6, 2026
0dde6de
feat: implement file-access confinement for clet md (issue #38)
Copilot May 6, 2026
e315df5
fix: address code review - fix path traversal in IsUnderDirectory, re…
Copilot May 6, 2026
dffd97d
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
392b8f1
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
fb50b66
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
922b2e7
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
70f7a98
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
4979a8d
Potential fix for pull request finding 'Useless assignment to local v…
tig May 6, 2026
c1a37c5
Potential fix for pull request finding 'Generic catch clause'
tig May 6, 2026
2c8e1d3
Potential fix for pull request finding 'Call to 'System.IO.Path.Combi…
tig May 6, 2026
af890c9
Potential fix for pull request finding 'Call to 'System.IO.Path.Combi…
tig May 6, 2026
ba4c57d
Potential fix for pull request finding 'Generic catch clause'
tig May 6, 2026
921f4c2
fix: qualify SecurityException with System.Security namespace to fix …
Copilot May 6, 2026
59f7585
Merge remote-tracking branch 'origin/develop' into copilot/fix-clet-m…
Copilot May 6, 2026
600741b
Merge remote-tracking branch 'origin/develop' into copilot/fix-clet-m…
Copilot May 6, 2026
ee83ab4
Add local markdown link navigation using FileAccessPolicy
tig May 6, 2026
d2238a8
Support directories in --allow-file for FileAccessPolicy
tig May 6, 2026
d2ce9cd
Make status bar URL a clickable Link view
tig May 6, 2026
0ef660b
Enable mouse highlight on status link when showing a URL
tig May 6, 2026
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
27 changes: 21 additions & 6 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,28 @@ The trust boundary is between the shell/agent layer and the clet CLI host. Every

### File access scope

**Threat:** `clet pick-file` or `clet pick-directory` could be used to navigate to sensitive directories. `clet md` could be pointed at sensitive files.
**Threat:** `clet md` could be used as an arbitrary file-read primitive in agent contexts. An indirect-prompt-injection could instruct an agent to run `clet md /home/$USER/.aws/credentials`, exfiltrating file contents via the rendered ANSI or `--cat` output. Similarly, glob patterns like `clet md '/etc/*.conf'` could enumerate and read multiple files.

**Mitigation:**
- File pickers use TG's `OpenDialog`, which honors OS-level file permissions and sandboxing.
- `clet md` reads files via `File.ReadAllText()` — standard OS permission checks apply.
- No privilege escalation: clet runs as the invoking user, never setuid/setgid.
- The `--root` option on `pick-file`/`pick-directory` constrains the starting directory but does not prevent navigation outside it (that's the OS sandbox's job).
**Mitigation (D-030):** `clet md` enforces a file-access confinement policy:

1. **Extension allowlist:** Only `.md`, `.markdown`, and `.txt` files are permitted by default.
2. **Working directory confinement:** Files must reside under the process working directory. Paths outside the cwd are refused.
3. **Per-file size cap:** 16 MiB maximum per file.
4. **Aggregate size cap:** 32 MiB maximum across all glob-expanded files.
5. **Glob file count cap:** Maximum 128 files per glob expansion.
6. **Binary content rejection:** Files containing NUL bytes in the first 8 KiB are refused (prevents accidentally rendering binary files).

**Escape hatches:**
- `--allow-file <path>` (repeatable): Explicitly allows a specific file path, bypassing extension and cwd checks. The file must still pass size and binary checks.
- `--allow-binary`: Permits binary content (disables NUL-byte detection).

**Rationale:** In agent contexts, positional arguments to `clet md` may be attacker-influenced. The default-deny policy prevents exfiltration while `--allow-file` provides an explicit opt-in for legitimate use cases where the caller controls the argument list.

**`pick-file` / `pick-directory` `--root` option:**
- `--root` sets the *starting directory* for the file picker dialog. It is **not** a sandbox.
- The user can navigate freely outside the root directory via the dialog's path bar.
- This is a deliberate design choice: the user is interactively choosing files, not an attacker. The OS file permission model is the sandboxing mechanism for interactive pickers.
- The help text explicitly documents this: "Starting directory (not a sandbox — user can navigate freely)."

### Plugin loading exclusion

Expand Down
24 changes: 24 additions & 0 deletions specs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,27 @@ This is clet's own defense. TG's cell model is treated as defense-in-depth, not
**Status.** Active.

**Pointers.** D-017, `src/Clet/Clets/Viewer/MarkdownClet.cs` (LinkClicked handler), `docs/threat-model.md` (Markdown link policy section), `specs/clet-spec.md` (Appendix A), `tests/Clet.IntegrationTests/MarkdownCletIntegrationTests.cs` (link safety tests).

---

## D-032: `clet md` file-access confinement policy (Active)

**Context.** `clet md FILE` is an arbitrary file-read primitive. In agent contexts, positional arguments may be attacker-influenced via indirect prompt injection. An attacker could instruct an agent to run `clet md /home/$USER/.aws/credentials`, exfiltrating file content through the rendered ANSI output or `--cat` stdout. Glob patterns (`clet md '/etc/*.conf'`) amplify this to directory enumeration. Issue #38 documents the full threat surface.

**Decision.** Default-deny file access policy with explicit opt-in escape hatches:
1. **Extension allowlist:** `.md`, `.markdown`, `.txt` only.
2. **Working directory confinement:** Files must be under the process cwd.
3. **Per-file size cap:** 16 MiB.
4. **Aggregate size cap:** 32 MiB across all glob matches.
5. **Glob count cap:** 128 files maximum.
6. **Binary rejection:** NUL byte in first 8 KiB refuses the file.
7. **`--allow-file <path>`** (repeatable): Bypasses extension + cwd checks for the named path. Size and binary checks still apply.
8. **`--allow-binary`:** Disables NUL-byte detection.

For `pick-file`/`pick-directory`, `--root` remains a starting directory, not a sandbox. The interactive file picker is user-driven and OS-permission-gated; confinement is the OS sandbox's responsibility, not clet's. Help text updated to make this explicit.

**Why not confine `pick-*`?** The user is interactively choosing files. Constraining navigation would break legitimate workflows and provide false security (the user can always run `ls` separately). The agent-context risk is in *non-interactive* file reads (`clet md`), not interactive dialogs.

**Status.** Active.

**Pointers.** `src/Clet/Hosting/FileAccessPolicy.cs`, `src/Clet/Hosting/AliasDispatcher.cs` (`ResolveViewerContent`), `src/Clet/Clets/Viewer/MarkdownClet.cs` (`ExpandFiles`), `src/Clet/Hosting/CommandLineRoot.cs` (`--allow-file`, `--allow-binary` parsing), `docs/threat-model.md` ("File access scope" section), issue #38.
6 changes: 6 additions & 0 deletions src/Clet/Abstractions/CletRunOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ internal sealed record CletRunOptions
public int? Rows { get; init; }
public IReadOnlyDictionary<string, string>? CletOptions { get; init; }
public IReadOnlyList<string>? Arguments { get; init; }

/// <summary>Paths explicitly allowed for file reading (bypasses extension + cwd checks).</summary>
public IReadOnlyList<string>? AllowedFiles { get; init; }

/// <summary>When true, binary file content (NUL bytes) is permitted.</summary>
public bool AllowBinary { get; init; }
}
2 changes: 1 addition & 1 deletion src/Clet/Clets/Input/PickDirectoryClet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ internal sealed class PickDirectoryClet : IClet<string?>

public IReadOnlyList<CletOptionDescriptor> Options =>
[
new ("root", "r", typeof (string), "Starting directory.", false, null),
new ("root", "r", typeof (string), "Starting directory (not a sandbox — user can navigate freely).", false, null),
];

public async Task<CletRunResult<string?>> RunAsync (
Expand Down
2 changes: 1 addition & 1 deletion src/Clet/Clets/Input/PickFileClet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
public IReadOnlyList<CletOptionDescriptor> Options =>
[
new ("multi", "m", typeof (bool), "Allow selecting multiple files.", false, "false"),
new ("root", "r", typeof (string), "Starting directory.", false, null),
new ("root", "r", typeof (string), "Starting directory (not a sandbox — user can navigate freely).", false, null),
new ("filter", "f", typeof (string), "File type filter (e.g. \"*.cs\").", false, null),
];

Expand Down Expand Up @@ -101,7 +101,7 @@

foreach (string p in sorted)
{
arr.Add (JsonValue.Create (p));

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating JsonValue instances with non-primitive types requires generating code at runtime.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. Creating JsonValue instances with non-primitive types is not compatible with trimming. It can result in non-primitive types being serialized, which may have their members trimmed.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating JsonValue instances with non-primitive types requires generating code at runtime.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. Creating JsonValue instances with non-primitive types is not compatible with trimming. It can result in non-primitive types being serialized, which may have their members trimmed.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating JsonValue instances with non-primitive types requires generating code at runtime.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. Creating JsonValue instances with non-primitive types is not compatible with trimming. It can result in non-primitive types being serialized, which may have their members trimmed.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating JsonValue instances with non-primitive types requires generating code at runtime.

Check warning on line 104 in src/Clet/Clets/Input/PickFileClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Using member 'System.Text.Json.Nodes.JsonArray.Add<T>(T)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. Creating JsonValue instances with non-primitive types is not compatible with trimming. It can result in non-primitive types being serialized, which may have their members trimmed.
}

return new () { Status = CletRunStatus.Ok, Value = arr };
Expand Down
157 changes: 148 additions & 9 deletions src/Clet/Clets/Viewer/MarkdownClet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,17 @@

if (options.Arguments is { Count: > 0 })
{
files = ExpandFiles (options.Arguments);
FileAccessPolicy policy = new (
Directory.GetCurrentDirectory (),
options.AllowedFiles,
options.AllowBinary);

files = ExpandFiles (options.Arguments, policy, out string? policyError);

if (policyError is not null)
{
return new () { Status = CletRunStatus.Error, ErrorCode = "file-access-denied", ErrorMessage = policyError };
}

if (files.Count == 0)
{
Expand Down Expand Up @@ -94,6 +104,15 @@
return new () { Status = CletRunStatus.Error, ErrorCode = "io", ErrorMessage = "No file specified. Usage: clet md <file.md>" };
}

// Track current file directory for resolving relative links
string? currentFileDir = files.Count > 0 ? Path.GetDirectoryName (Path.GetFullPath (files [0])) : null;

// File access policy for link navigation (reuse the same confinement as file loading)
FileAccessPolicy linkPolicy = new (
Directory.GetCurrentDirectory (),
options.AllowedFiles,
options.AllowBinary);

// Parse --theme option
ThemeName syntaxTheme = ThemeName.DarkPlus;

Expand Down Expand Up @@ -123,13 +142,30 @@

Shortcut lineCountShortcut = new () { Title = "0 lines", MouseHighlightStates = MouseState.None, Enabled = false };
Shortcut fileSizeShortcut = new () { Title = "0 B", MouseHighlightStates = MouseState.None, Enabled = false };
Shortcut statusShortcut = new (Key.Empty, "Ready", null) { MouseHighlightStates = MouseState.None };

// Status link — shows the current filename or a clickable URL when the user
// hovers/clicks a hyperlink in the markdown. Clicking the link in the status
// bar opens it in the default browser.
Link statusLink = new () { Text = "Ready", CanFocus = false };
Shortcut statusShortcut = new () { CommandView = statusLink, MouseHighlightStates = MouseState.None };

// --- MarkdownView event wiring ---

markdownView.LinkClicked += (_, e) =>
{
statusShortcut.Title = e.Url;
// Try to navigate to local .md files that pass the file access policy
if (currentFileDir is not null && TryResolveLocalMarkdownLink (e.Url, currentFileDir, linkPolicy, out string? resolvedPath))
{
LoadFile (resolvedPath);

Check warning on line 159 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 159 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 159 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.

Check warning on line 159 in src/Clet/Clets/Viewer/MarkdownClet.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'filePath' in 'void LoadFile(string filePath)'.
e.Handled = true;

return;
}

// Show URL in status bar as a clickable link
statusLink.Text = e.Url;
statusLink.Url = e.Url;
statusShortcut.MouseHighlightStates = MouseState.In;
e.Handled = true;
};

Expand Down Expand Up @@ -235,7 +271,9 @@
string sanitized = TerminalEscapeSanitizer.Sanitize (content)!;
markdownView.Text = sanitized;
fileSizeShortcut.Title = FormatFileSize (System.Text.Encoding.UTF8.GetByteCount (sanitized));
statusShortcut.Title = options.Title ?? "(inline)";
statusLink.Text = options.Title ?? "(inline)";
statusLink.Url = string.Empty;
statusShortcut.MouseHighlightStates = MouseState.None;
}

};
Expand All @@ -258,18 +296,88 @@

void LoadFile (string filePath)
{
string fileContent = TerminalEscapeSanitizer.Sanitize (File.ReadAllText (filePath))!;
string fullPath = Path.GetFullPath (filePath);
string fileContent = TerminalEscapeSanitizer.Sanitize (File.ReadAllText (fullPath))!;
markdownView.Text = fileContent;

FileInfo fileInfo = new (filePath);
currentFileDir = Path.GetDirectoryName (fullPath);

FileInfo fileInfo = new (fullPath);
fileSizeShortcut.Title = FormatFileSize (fileInfo.Length);
statusShortcut.Title = Path.GetFileName (filePath);
statusLink.Text = Path.GetFileName (fullPath);
statusLink.Url = string.Empty;
}
}

/// <summary>
/// Resolves a link URL to a local markdown file path if it passes the file access policy.
/// </summary>
internal static bool TryResolveLocalMarkdownLink (
string url,
string currentDir,
FileAccessPolicy policy,
out string? resolvedPath)
{
resolvedPath = null;

// Strip fragment (e.g. #section)
int fragmentIndex = url.IndexOf ('#');
string pathPart = fragmentIndex >= 0 ? url [..fragmentIndex] : url;

if (string.IsNullOrWhiteSpace (pathPart))
{
return false;
}

// Handle file:// URIs
if (pathPart.StartsWith ("file://", StringComparison.OrdinalIgnoreCase))
{
if (!Uri.TryCreate (pathPart, UriKind.Absolute, out Uri? fileUri) || !fileUri.IsFile)
{
return false;
}

pathPart = fileUri.LocalPath;
}
// Reject non-local schemes (http://, https://, mailto:, etc.)
else if (pathPart.Contains ("://", StringComparison.Ordinal))
{
return false;
}

string fullPath;

try
{
fullPath = Path.IsPathRooted (pathPart)
? Path.GetFullPath (pathPart)
: Path.GetFullPath (Path.Combine (currentDir, pathPart));
}
catch
{
return false;
}

if (!File.Exists (fullPath))
{
return false;
}

// Delegate all security checks (extension, cwd confinement, binary, size) to the policy
if (policy.CheckFile (fullPath) is not null)
{
return false;
}

resolvedPath = fullPath;

return true;
}

private static List<string> ExpandFiles (IReadOnlyList<string> patterns)
private static List<string> ExpandFiles (IReadOnlyList<string> patterns, FileAccessPolicy policy, out string? error)
{
List<string> result = [];
error = null;

foreach (string pattern in patterns)
{
Expand All @@ -280,11 +388,42 @@

if (Directory.Exists (directory))
{
result.AddRange (Directory.GetFiles (directory, filePattern));
string[] matched = Directory.GetFiles (directory, filePattern);
string? globError = policy.CheckGlobAggregate (matched);

if (globError is not null)
{
error = globError;

return [];
}

foreach (string file in matched)
{
string? violation = policy.CheckFile (file);

if (violation is not null)
{
error = violation;

return [];
}

result.Add (Path.GetFullPath (file));
}
}
}
else if (File.Exists (pattern))
{
string? violation = policy.CheckFile (pattern);

if (violation is not null)
{
error = violation;

return [];
}

result.Add (Path.GetFullPath (pattern));
}
else
Expand Down
Loading
Loading