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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ agentshield/
├── src/
│ ├── lib.rs # Public API: scan(), render_report()
│ ├── error.rs # ShieldError (thiserror)
│ ├── bin/cli.rs # Clap CLI: scan, list-rules, init, suppress, list-suppressions, certify
│ ├── bin/cli.rs # Clap CLI: discover, scan, setup, reporting, runtime
│ ├── discovery/ # CLI-private registry, parsers, and safe filesystem reads
│ ├── ir/ # Intermediate Representation (ScanTarget)
│ │ ├── mod.rs # ScanTarget, Framework, SourceFile, ArgumentSource
│ │ ├── tool_surface.rs # Tool definitions, permissions
Expand Down Expand Up @@ -102,6 +103,7 @@ cargo run -- scan tests/fixtures/mcp_servers/vuln_cmd_inject
cargo run -- scan . --ignore-tests --format html --output report.html
cargo run -- scan . --write-baseline baseline.json
cargo run -- scan . --baseline baseline.json
cargo run -- discover --no-default-paths --root .
cargo run -- list-rules
cargo run -- suppress SHIELD-001 src/tools.py:42 --reason "accepted risk"
cargo run -- list-suppressions
Expand Down
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.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ url = "2.5"
tokio = { version = "1", features = ["full"], optional = true }
parking_lot = { version = "0.12", optional = true }

[target.'cfg(unix)'.dependencies]
# Atomic handle-relative discovery reads. Already present transitively via tempfile.
rustix = { version = "1.1.3", features = ["fs"] }

[dev-dependencies]
tempfile = "3.9"
pretty_assertions = "1.4"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ cargo install --git https://github.com/aiconnai/agentshield --tag v0.8.7 --featu
# First-run setup: config + explained first scan
agentshield quickstart

# Discover allowlisted local client configs without executing or scanning them
agentshield discover --no-default-paths --root .

# Understand the gate, coverage, confidence, and next actions
agentshield scan . --ignore-tests --fail-on high --explain

Expand Down Expand Up @@ -247,6 +250,7 @@ AgentShield runs all matching adapters in a repository instead of stopping at th

| Command | Purpose |
|---------|---------|
| `agentshield discover` | Read allowlisted local client configuration paths without execution, network access, or automatic scanning; use `--no-default-paths`, repeated `--root`, `--format console\|json`, and `--explain` to control consent and output. |
| `agentshield scan [path]` | Scan an agent extension directory and emit console, JSON, SARIF, or HTML output. |
| `agentshield scan [path] --explain` | Print a console-only gate, coverage, confidence, grouped findings, next-actions, and limits summary. |
| `agentshield quickstart [path]` | Create first-run config, suggest CI setup, run the first scan, and explain the result. |
Expand Down
30 changes: 30 additions & 0 deletions src/bin/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use std::path::PathBuf;
use std::process;

#[path = "cli/discover.rs"]
mod discover;
#[path = "../discovery/mod.rs"]
mod discovery;
#[path = "cli/reporting.rs"]
mod reporting;
#[path = "cli/rules.rs"]
Expand All @@ -14,6 +18,7 @@ mod setup;

use clap::{Parser, Subcommand};

use discover::cmd_discover;
use reporting::{cmd_certify, cmd_list_suppressions, cmd_suppress};
use rules::cmd_list_rules;
#[cfg(feature = "runtime")]
Expand Down Expand Up @@ -41,6 +46,25 @@ struct Cli {

#[derive(Subcommand)]
enum Commands {
/// Discover allowlisted local client configurations without scanning them
Discover {
/// Do not inspect known configuration paths under the effective profile
#[arg(long)]
no_default_paths: bool,

/// Inspect allowlisted configuration layouts under this explicit root
#[arg(long, value_name = "PATH")]
root: Vec<PathBuf>,

/// Output format (console, json)
#[arg(long, short = 'f', default_value = "console")]
format: String,

/// Explain paths, limits, and symlink policy before reading
#[arg(long)]
explain: bool,
},

/// Scan an agent extension for security issues
Scan {
/// Path to the extension directory
Expand Down Expand Up @@ -282,6 +306,12 @@ fn main() {
let cli = Cli::parse();

let result = match cli.command {
Commands::Discover {
no_default_paths,
root,
format,
explain,
} => cmd_discover(!no_default_paths, root, format, explain),
Commands::Scan {
path,
config,
Expand Down
188 changes: 188 additions & 0 deletions src/bin/cli/discover.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use std::path::PathBuf;

use agentshield::error::{Result, ShieldError};

use crate::discovery::{
discover, registry, DiscoveryBase, DiscoveryEnvelope, DiscoveryRequest, DIAGNOSTIC_CODES,
ENTRY_STATES, MAX_AGGREGATE_BYTES, MAX_CANDIDATE_FILES_PER_INVOCATION,
MAX_CANDIDATE_FILES_PER_ROOT, MAX_CONFIG_BYTES, MAX_DEPTH_PER_ROOT,
MAX_DIRECTORIES_PER_INVOCATION, MAX_DIRECTORIES_PER_ROOT, MAX_ENTRIES_PER_INVOCATION,
MAX_OPENED_CONFIGS_PER_INVOCATION, MAX_OPENED_CONFIGS_PER_ROOT, SOURCE_STATUSES,
};

pub(crate) fn cmd_discover(
include_default_paths: bool,
roots: Vec<PathBuf>,
format: String,
explain: bool,
) -> Result<i32> {
let format = DiscoveryOutputFormat::parse(&format)?;
let request = DiscoveryRequest {
include_default_paths,
effective_profile: effective_profile(),
roots,
};

if explain {
eprint!("{}", render_explanation(&request));
}

let envelope = discover(&request).map_err(ShieldError::Config)?;
match format {
DiscoveryOutputFormat::Console => print!("{}", render_console(&envelope)),
DiscoveryOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&envelope)?),
}
Ok(0)
}

fn effective_profile() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiscoveryOutputFormat {
Console,
Json,
}

impl DiscoveryOutputFormat {
fn parse(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"console" => Ok(Self::Console),
"json" => Ok(Self::Json),
_ => Err(ShieldError::Config(format!(
"unsupported discovery format '{value}'; expected console or json"
))),
}
}
}

fn render_explanation(request: &DiscoveryRequest) -> String {
let default_descriptors = registry()
.iter()
.filter(|descriptor| descriptor.base == DiscoveryBase::EffectiveProfile)
.map(|descriptor| format!(" - ~/{}\n", descriptor.relative_path))
.collect::<String>();
let roots = request
.roots
.iter()
.enumerate()
.map(|(index, _)| format!(" - $ROOT[{index}]\n"))
.collect::<String>();
let entry_states = ENTRY_STATES
.iter()
.map(|state| state.as_str())
.collect::<Vec<_>>()
.join(", ");
let source_statuses = SOURCE_STATUSES
.iter()
.map(json_label)
.collect::<Vec<_>>()
.join(", ");
let diagnostic_codes = DIAGNOSTIC_CODES
.iter()
.map(json_label)
.collect::<Vec<_>>()
.join(", ");

format!(
"Discovery plan (read-only; no scan or execution)\n\
Default paths: {}\n{}\
Explicit roots:\n{}\
Limits: depth={MAX_DEPTH_PER_ROOT}, directories={MAX_DIRECTORIES_PER_ROOT}/root \
and {MAX_DIRECTORIES_PER_INVOCATION}/invocation, candidates={MAX_CANDIDATE_FILES_PER_ROOT}/root \
and {MAX_CANDIDATE_FILES_PER_INVOCATION}/invocation, opened={MAX_OPENED_CONFIGS_PER_ROOT}/root \
and {MAX_OPENED_CONFIGS_PER_INVOCATION}/invocation, file_bytes={MAX_CONFIG_BYTES}, \
aggregate_bytes={MAX_AGGREGATE_BYTES}, entries={MAX_ENTRIES_PER_INVOCATION}\n\
Symlinks and special files are never followed or read. Platforms without an atomic \
no-follow primitive fail closed.\n\
Source statuses: {source_statuses}.\n\
Entry states: {entry_states}.\n\
Diagnostic codes: {diagnostic_codes}.\n",
if request.include_default_paths {
"enabled"
} else {
"disabled"
},
if request.include_default_paths {
default_descriptors
} else {
String::new()
},
if roots.is_empty() {
" (none)\n".to_owned()
} else {
roots
},
)
}

fn render_console(envelope: &DiscoveryEnvelope) -> String {
let mut output = format!(
"AgentShield discovery: {} source(s), {} entry/entries, {} diagnostic(s)\n",
envelope.summary.sources, envelope.summary.entries, envelope.summary.diagnostics
);

for source in &envelope.sources {
output.push_str(&format!(
"\n{} [{}] ({})\n",
source.path_ref,
source.client_id.as_str(),
json_label(&source.status)
));
for entry in envelope
.entries
.iter()
.filter(|entry| entry.source_id == source.source_id)
{
output.push_str(&format!(
" - {}: {} ({})\n",
entry.declared_name,
json_label(&entry.state),
json_label(&entry.support_status)
));
}
for diagnostic in envelope
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.source_id == source.source_id)
{
output.push_str(&format!(" ! {}\n", json_label(&diagnostic.code)));
}
}
output
}

fn json_label<T: serde::Serialize>(value: &T) -> String {
serde_json::to_value(value)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_else(|| "unknown".to_owned())
}

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

#[test]
fn invalid_format_is_an_invocation_error() {
let error = DiscoveryOutputFormat::parse("sarif").expect_err("sarif is unsupported");
assert!(error.to_string().contains("console or json"));
assert_eq!(error.exit_code(), 2);
}

#[test]
fn explanation_redacts_explicit_roots() {
let request = DiscoveryRequest {
include_default_paths: false,
effective_profile: None,
roots: vec![PathBuf::from("/Users/alice/private")],
};
let explanation = render_explanation(&request);
assert!(explanation.contains("$ROOT[0]"));
assert!(!explanation.contains("alice"));
assert!(!explanation.contains("/Users"));
}
}
Loading
Loading