Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4a74e96
Write CLI reports through no-follow directory descriptors
yashrajp22 Oct 7, 2026
dd1c2fb
Format secure report writer and race checks
yashrajp22 Oct 7, 2026
a8c13b6
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 7, 2026
0190455
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 8, 2026
8b0ba41
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 8, 2026
8b500a1
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 8, 2026
bb451b6
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 8, 2026
cb1855d
fix(output): validate support early and preserve stdout workflows
yashrajp22 Oct 9, 2026
55f7600
test(batch): supply language in output fixture
yashrajp22 Oct 9, 2026
aab9c4c
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 9, 2026
35c01d1
merge: retain safe output changes alongside TM1 window fix
yashrajp22 Oct 9, 2026
afbb16b
merge: preserve baseline metadata through anchored atomic output
yashrajp22 Oct 9, 2026
a54a401
test: retain baseline regular-file error contract
yashrajp22 Oct 9, 2026
791f919
fix: tolerate bounded concurrent baseline replacements
yashrajp22 Oct 9, 2026
57a63d5
merge: preserve security fixes with latest upstream urllib sink fix
yashrajp22 Oct 9, 2026
102bec1
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 9, 2026
fb2dc65
Merge branch 'main' into yashraj/fix-cli-report-output-race
rng1995 Oct 9, 2026
9eac105
Merge remote-tracking branch 'origin/main' into yashraj/fix-cli-repor…
yashrajp22 Oct 14, 2026
5fc9bdf
fix(output): preserve write permissions and clarify safe baseline errors
yashrajp22 Oct 14, 2026
039eb6d
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
f5d5554
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
124c2e0
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
8070008
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
ebbb8eb
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
a068679
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 14, 2026
b2f8206
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 15, 2026
8ef92a9
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 15, 2026
4bd0ae1
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
c593600
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
2c8da48
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
7755c70
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
0acd197
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
c4950e9
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 16, 2026
7d1a21e
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 19, 2026
7e48091
Merge branch 'main' into yashraj/fix-cli-report-output-race
github-actions[bot] Oct 19, 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
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 @@ -792,6 +792,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 @@ -157,8 +158,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 @@ -203,8 +206,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."""
options["timeout"] = timeout
Expand Down Expand Up @@ -367,6 +375,13 @@ def _print(*args: object, **kwargs: object) -> None:
else:
print(" ".join(str(a) for a in args), file=file) # type: ignore[arg-type]

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 @@ -391,8 +406,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 @@ -503,13 +517,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 @@ -524,7 +535,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
31 changes: 24 additions & 7 deletions docs/SUPPRESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ 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
Comment thread
yashrajp22 marked this conversation as resolved.
available on Windows too. Redirect to a **new file outside the scan target**,
then replace an existing baseline only after the command exits with code 0.
Redirecting directly over the old file truncates it before the scan starts,
even when scanning fails. On Windows PowerShell 5.1, use `cmd /c` for the
redirection or `Out-File -Encoding utf8`; its default `>` encoding is UTF-16LE,
which the baseline loader does not accept. Check `$LASTEXITCODE` before replacing
the old baseline. 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 +138,21 @@ 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 -` redirected to a new file in those cases. Do not redirect
over the shared baseline: that rewrites the inode in place and can change other
hard-linked files. 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 @@ -83,6 +84,7 @@
dump_baseline,
effective_findings,
load_baseline,
serialize_baseline,
source_content_key,
)
from skillspector.terminal_text import visible_terminal_text as _visible_terminal_text
Expand Down Expand Up @@ -373,7 +375,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 @@ -690,6 +692,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 @@ -720,7 +729,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 @@ -3276,7 +3285,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 @@ -3304,7 +3313,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 @@ -3337,7 +3346,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, markup=False, emoji=False)
Expand Down Expand Up @@ -3418,7 +3427,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 @@ -3455,12 +3464,16 @@ def baseline(
_exit_on_unsupported_llm_controls(no_llm)
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 @@ -3491,10 +3504,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
Loading