Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ docker run --rm \
**Write a report to the host filesystem** by writing to the mounted directory:

```bash
docker run --rm \
docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD:/scan" \
skillspector scan ./my-skill/ --no-llm --format json --output report.json
```
Expand Down Expand Up @@ -776,6 +776,17 @@ Options:
skillspector baseline <path> [-o FILE] [--no-llm] [--reason TEXT]
```

Report files and new baselines use private permissions (0600). Existing baselines
retain their access metadata when safely replaced; updates requiring in-place
writes are refused. All file output uses atomic replacement. The parent directory
must be writable; symlinked
parents and non-regular destinations such as devices are refused. On macOS,
ancestor directories must also be readable. File output requires POSIX no-follow
directory operations and is unavailable on Windows. Unsupported file output fails
before analysis. Omit `--output` for scan/batch stdout, or use
`skillspector baseline <path> -o -` for JSON on stdout, then redirect only to a
trusted destination. Run Docker with your user ID as above so you can read its files.

## Integrating SkillSpector

SkillSpector is built to be driven by other tools (CI pipelines, install gates, editor integrations). Its exit code and JSON output are a stable contract.
Expand Down
39 changes: 27 additions & 12 deletions contrib/batch_scan/batch_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
from types import SimpleNamespace
from uuid import uuid4

from skillspector.file_output import require_secure_file_output, write_text_no_follow
from skillspector.logging_config import set_level

from .api_pool import create_api_key_pool_from_env
Expand Down Expand Up @@ -155,8 +156,10 @@ def _kill_worker_group(pid: int) -> None:
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=5, check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
pass
Expand Down Expand Up @@ -201,8 +204,13 @@ def _scan_skill_process(


def _scan_skill_bounded(
skill_dir: Path, root: Path, *, api_pool=None, timeout: float = 90,
startup_timeout: float = 90, **options
skill_dir: Path,
root: Path,
*,
api_pool=None,
timeout: float = 90,
startup_timeout: float = 90,
**options,
) -> tuple[dict[str, object], str | None, str]:
"""Enforce the wall-clock limit on actual work, including local analysis."""
owner = uuid4().hex
Expand Down Expand Up @@ -365,6 +373,13 @@ def _print(*args: object, **kwargs: object) -> None:
)
args = parser.parse_args()

if args.output:
try:
require_secure_file_output()
except ValueError as exc:
_print(f"Error: {exc}", markup=False, file=sys.stderr)
sys.exit(2)

if args.verbose:
set_level("DEBUG")

Expand All @@ -389,8 +404,7 @@ def _print(*args: object, **kwargs: object) -> None:

# -- Header --------------------------------------------------------------
pool_note = (
f", [green]{api_pool.keys_configured} keys "
f"({api_pool.total_capacity} slots)[/green]"
f", [green]{api_pool.keys_configured} keys ({api_pool.total_capacity} slots)[/green]"
if api_pool
else ""
)
Expand Down Expand Up @@ -501,13 +515,10 @@ def _print(*args: object, **kwargs: object) -> None:
f"{snap['total_requests_served']} requests served",
]
if snap.get("peak_active_requests", 0) > 0:
_parts.append(
f"peak {snap['peak_active_requests']}/{snap['total_capacity']} slots"
)
_parts.append(f"peak {snap['peak_active_requests']}/{snap['total_capacity']} slots")
if snap.get("rate_limits_hit", 0) > 0:
_parts.append(
f"{snap['rate_limits_hit']} rate-limit(s), "
f"{snap['retry_successes']} retried"
f"{snap['rate_limits_hit']} rate-limit(s), {snap['retry_successes']} retried"
)
_parts.append(f"{snap['keys_configured']} keys")
_print(f"\n[dim]API Pool: {', '.join(_parts)}[/dim]")
Expand All @@ -522,7 +533,11 @@ def _print(*args: object, **kwargs: object) -> None:
report_body = format_markdown(results)

if args.output:
args.output.write_text(report_body, encoding="utf-8")
try:
write_text_no_follow(args.output, report_body)
except (OSError, ValueError) as exc:
_print(f"Error: {exc}", markup=False, file=sys.stderr)
sys.exit(2)
_print(f"\n[green]Batch report saved to:[/green] {display(args.output)}")
else:
if fmt == "terminal":
Expand Down
23 changes: 16 additions & 7 deletions docs/SUPPRESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ prevents sensitive rule text from creating a finding against itself or entering
regenerated fingerprints. Other baseline files and sibling YAML/JSON files
remain in normal scan scope unless they are selected with `--baseline` or `-o`.

Use `skillspector baseline ./my-skill/ -o -` to emit JSON on stdout. This is
available on Windows too; redirect only to a trusted destination. File output
requires POSIX no-follow directory operations and fails before analysis when
unsupported. New baselines use mode 0600; existing baselines retain access metadata
when safely replaced. Updates requiring in-place writes are refused. The parent
must be writable, symlinked parents and non-regular files are refused, and macOS
also requires readable ancestor directories.

## Baseline file format

YAML or JSON (the `.json` extension selects JSON output when generating). Two
Expand Down Expand Up @@ -124,18 +132,19 @@ Only identical exact fingerprints share an entry. A baseline generated by an
affected version may omit these occurrences; regenerate it to include them.
If the complete baseline exceeds the loader's size or record limits, generation
fails before replacing the output file instead of writing a partial baseline.
After validation, generation normally writes a complete temporary file beside the
After validation, generation writes a complete temporary file beside the
destination and atomically replaces the output. A failed write or replacement
preserves the existing baseline. Existing destinations must be writable regular
files; symlinks and special files are rejected. Replacement requires a writable
parent directory. On POSIX, new files grant access only to their owner; existing
ordinary permission bits and ownership are preserved, including group-write access.
When a non-owner has write access to an existing file, or the owner cannot
assign the file's group because it is not a member, generation updates its
validated descriptor in place and preserves its ACLs. This shared-file fallback
serializes cooperating writers, but readers can see a partial write and an I/O
failure or interruption can leave a partial baseline. Use an owner-managed output
when atomic replacement is required.
Access ACLs are copied through validated descriptors before any content is written.
A non-owner update, or an update that cannot preserve the destination's group,
fails without changing the existing baseline. Use a new owner-managed output
file or `--output -` in those cases. Generation does not fall back to in-place
writes, which would lose the atomic replacement and hard-link safety guarantees.
All path operations use the same no-follow parent descriptor so a directory swap
cannot redirect the write.

Every v2 entry must be a mapping with a 64-hex-character `sha256:` hash and a
non-empty `reason`. `rule_id` and `file` are informational fields for reviewers.
Expand Down
40 changes: 28 additions & 12 deletions src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from skillspector import __version__, transitive
from skillspector.cleanup import TempDirTracker, cleanup_result
from skillspector.constants import RISK_THRESHOLD
from skillspector.file_output import require_secure_file_output, write_text_no_follow
from skillspector.graph_proxy import graph
from skillspector.input_handler import validate_local_input_path
from skillspector.inspection_ledger import (
Expand Down Expand Up @@ -82,6 +83,7 @@
dump_baseline,
effective_findings,
load_baseline,
serialize_baseline,
)

logger = get_logger(__name__)
Expand Down Expand Up @@ -359,7 +361,7 @@ def _write_result(
"""Write report_body to file or stdout. Uses sarif_report if report_body missing."""
report_body = _result_body(result)
if output:
Path(output).write_text(report_body, encoding="utf-8")
write_text_no_follow(output, report_body)
Comment thread
yashrajp22 marked this conversation as resolved.
if format == FormatChoice.terminal:
console.print(f"\n[green]Report saved to:[/green] {output}")
else:
Expand Down Expand Up @@ -666,6 +668,13 @@ def scan(
)
raise typer.Exit(code=2)

if output is not None:
try:
require_secure_file_output()
except ValueError as exc:
err_console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(code=2) from exc

if mcp_registry:
if (
recursive
Expand Down Expand Up @@ -693,7 +702,7 @@ def scan(
)
report = json.dumps(result, indent=2)
if output:
output.write_text(report, encoding="utf-8")
write_text_no_follow(output, report)
console.print(f"Report saved to: {output}")
else:
print(report)
Expand Down Expand Up @@ -3272,7 +3281,7 @@ def _scan_multi_skill(
rendered = json.dumps(combined, indent=2)
_ensure_recursive_output_bound(rendered)
if output is not None:
Path(output).write_text(rendered, encoding="utf-8")
write_text_no_follow(output, rendered)
progress_console.print(f"[green]Combined report saved to:[/green] {output}")
else:
sys.stdout.write(rendered)
Expand Down Expand Up @@ -3300,7 +3309,7 @@ def _scan_multi_skill(
rendered = json.dumps(merged_sarif, indent=2)
_ensure_recursive_output_bound(rendered)
if output is not None:
Path(output).write_text(rendered, encoding="utf-8")
write_text_no_follow(output, rendered)
progress_console.print(f"[green]Combined report saved to:[/green] {output}")
else:
sys.stdout.write(rendered)
Expand Down Expand Up @@ -3333,7 +3342,7 @@ def _scan_multi_skill(
)
_ensure_recursive_output_bound(rendered)
if output is not None:
Path(output).write_text(rendered, encoding="utf-8")
write_text_no_follow(output, rendered)
progress_console.print(f"[green]Combined report saved to:[/green] {output}")
elif format is FormatChoice.terminal:
console.print(rendered)
Expand Down Expand Up @@ -3414,7 +3423,7 @@ def baseline(
typer.Option(
"--output",
"-o",
help="Where to write the baseline file (YAML; .json extension writes JSON).",
help="Baseline file (YAML; .json writes JSON), or - for JSON on stdout.",
),
] = Path(".skillspector-baseline.yaml"),
no_llm: Annotated[
Expand Down Expand Up @@ -3450,12 +3459,16 @@ def baseline(
"""
result = None
try:
to_stdout = str(output) == "-"
if not to_stdout:
require_secure_file_output()
if verbose:
set_level("DEBUG")
console.print("[dim]Scanning to build baseline...[/dim]")
err_console.print("[dim]Scanning to build baseline...[/dim]")
# output_format is irrelevant here; we consume findings, not report_body.
state = _scan_state(input_path, FormatChoice.json, no_llm)
state["baseline_path"] = os.path.abspath(output.expanduser())
if not to_stdout:
state["baseline_path"] = os.path.abspath(output.expanduser())
result = graph.invoke(state)
completeness_value = result.get("analysis_completeness")
completeness = completeness_value if isinstance(completeness_value, dict) else {}
Expand Down Expand Up @@ -3486,10 +3499,13 @@ def baseline(
file_cache=result.get("local_file_cache") or result.get("file_cache") or {},
scanner_version=__version__,
)
dump_baseline(data, output)
console.print(
f"[green]Wrote baseline with {len(findings)} suppressed finding(s) to:[/green] {output}"
)
if to_stdout:
sys.stdout.write(serialize_baseline(data, json_output=True) + "\n")
else:
dump_baseline(data, output)
console.print(
f"[green]Wrote baseline with {len(findings)} suppressed finding(s) to:[/green] {output}"
)
except typer.Exit:
raise
except (FileNotFoundError, ValueError) as e:
Expand Down
Loading