sharekit-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cde6c59b8fa8f0a9b7405d8f67ccba50390a1cfeb03a10628f39cfc7b1c823c
4
- data.tar.gz: 9d86a7780e71640e1597d57912113a9c154256eb799aa2e28e7408686dbb2844
3
+ metadata.gz: 7b29c0f30ebaaf2763d471c1ef587cc8fa1d6d32e1a1964ea6749f9b6e9fde48
4
+ data.tar.gz: 2afc9c73934a31825c8ef0e0531ab85b8e386cebf9daf0f2ec12bb34f994fce4
5
5
  SHA512:
6
- metadata.gz: 7bea1ae6dc74aab3e5a3a57d45e4c9ad986b046485f26020a2bbafbb1cb626250e30a748490b671cd6be9c78be62d729cdade72df9e6afcacb04496ca24bf8c5
7
- data.tar.gz: 8a02b6895b8318687478166602ec1341cc537b2d44e4b3351ab6771807c2df9679a07b380d99557c11caf50f9a9267221716e1db0823c6111e951df1f064585b
6
+ metadata.gz: 7e8314ef9e2cc42af165c8d18c9b6e1aced24c1de9a9ad7bec43a491d7c0f5929f479d939c8073c4739a2884a89f594a869ac0b47f6ef577074f0ce8e8e14357
7
+ data.tar.gz: af337d25884e2fce90ee4dc98855d1be0f6dee87c25c5c08064594f9d2fd1e107f54fed9e7bfc92a46656836863d5f291273e41dd6a7a0897c756b6c95696e8a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-07-26
4
+
5
+ - `scan --ai-triage` labels each finding `true_positive`, `false_positive` or
6
+ `uncertain` with a confidence and a one-line rationale, via RubyLLM structured
7
+ output. Works against any RubyLLM provider; `--provider ollama` needs no setup.
8
+ - `ruby_llm` is an **optional** dependency, not a runtime one: `scan` alone pulls
9
+ nothing new, and `--ai-triage` without it reports how to install it. Run
10
+ `gem install ruby_llm` to enable triage.
11
+ - Secret values are never transmitted, and there is no opt-out. Findings are
12
+ reduced to their shape (length plus Shannon entropy) before the prompt is
13
+ built. See `docs/adr/2026-07-26-redact-always-ai-triage.md`.
14
+ - Verdicts are advisory: the high-severity gate ignores them.
15
+
3
16
  ## [0.1.0] - 2026-07-25
4
17
 
5
18
  - Initial release
data/README.md CHANGED
@@ -20,6 +20,13 @@ idioms that don't have a clean TS equivalent:
20
20
  without the scanner caring which.
21
21
  - **`case/in` pattern matching** in `Reporter.format_line`, matching directly on the `Finding`'s
22
22
  deconstructed fields instead of manual attribute access.
23
+ - **`Data#with` for the triage pass.** Verdicts are layered onto findings by returning new
24
+ `Finding`s, so the scan result is never mutated and an untriaged finding is structurally
25
+ the same object it always was. The triage fields default to `nil` via a keyword-splat
26
+ `super`, which is how `Data.define` takes defaults at all.
27
+ - **`autoload` for the AI path.** `require "ruby_llm"` pulls in a provider stack that a plain
28
+ `scan` never touches, so `Triage` is resolved on first reference. One stdlib line instead of
29
+ an optional-dependency dance.
23
30
  - **Thor** for CLI dispatch instead of hand-parsed `ARGV`/flags.
24
31
  - **`git ls-files`, not a hand-rolled ignore-file parser.** `scan` defers to git for
25
32
  `.gitignore`-aware file listing when run inside a repo (via `Open3`, argv-array — no shell
@@ -27,17 +34,14 @@ idioms that don't have a clean TS equivalent:
27
34
 
28
35
  ## Install
29
36
 
30
- Not yet published to RubyGems. Install straight from git:
31
-
32
37
  ```bash
33
- gem install specific_install
34
- gem specific_install https://github.com/LucasSantana-Dev/sharekit-cli.git
38
+ gem install sharekit-cli
35
39
  ```
36
40
 
37
41
  Or add to a `Gemfile`:
38
42
 
39
43
  ```ruby
40
- gem "sharekit-cli", git: "https://github.com/LucasSantana-Dev/sharekit-cli.git"
44
+ gem "sharekit-cli"
41
45
  ```
42
46
 
43
47
  ## Usage
@@ -67,6 +71,52 @@ otherwise. `init` copies `~/.claude/CLAUDE.md` and `~/.cursor/.cursorrules` into
67
71
  (or writes placeholders if they don't exist) and scans everything it copies for secrets before
68
72
  reporting success.
69
73
 
74
+ ### AI triage
75
+
76
+ `--ai-triage` labels each finding as a real leak, a placeholder, or unclear, so a docs
77
+ sample stops looking exactly like a live key.
78
+
79
+ It needs one extra gem, which `scan` itself does not:
80
+
81
+ ```bash
82
+ gem install ruby_llm
83
+ ```
84
+
85
+ That is deliberate. A secret scanner shouldn't drag a provider stack into every install, so
86
+ `ruby_llm` is optional: `sharekit-cli scan` never loads it, and `--ai-triage` without it fails
87
+ with instructions rather than a `LoadError`.
88
+
89
+ ```bash
90
+ sharekit-cli scan --ai-triage # any configured provider
91
+ sharekit-cli scan --ai-triage --provider ollama --model qwen3:4b \
92
+ --assume-model-exists # fully local
93
+ ```
94
+
95
+ ```text
96
+ ⚠ Secret patterns detected:
97
+ config/secrets.env:1 [AWS Access Key ID] …_KEY=AKIAEXAMPLEKEY000000
98
+ → likely real (95% confident) Config path, not a test or docs fixture.
99
+ docs/example.md:3 [AWS Access Key ID] …ple `AKIAIOSFODNN7EXAMPLE…
100
+ → likely placeholder (98% confident) Documentation path, context says "for example".
101
+
102
+ ⚠ AI verdicts are advisory: the high-severity gate ignores them.
103
+ ```
104
+
105
+ **Secret values are never sent, and there is no flag to send them.** Before a prompt is
106
+ built, each finding's line is re-read from disk and every secret in it is replaced by its
107
+ shape alone, as `[REDACTED chars=20 entropy=3.8]`. What the model gets is the rule name,
108
+ the path, the line number, that shape, and whatever context survived. Redaction runs on the
109
+ whole line rather than the truncated `preview` for a reason: truncation can cut a secret so
110
+ that its own rule no longer matches it, and the fragment would survive. Reasoning is in
111
+ [the ADR](docs/adr/2026-07-26-redact-always-ai-triage.md).
112
+
113
+ Credentials come from `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. `--provider ollama` needs
114
+ neither and defaults to `http://localhost:11434/v1` (override with `OLLAMA_API_BASE`), which
115
+ keeps even the redacted shape on your machine.
116
+
117
+ Verdicts annotate; they never unblock. A model deciding a live key isn't a key is not a
118
+ failure mode worth having, so the gate stays independent of it.
119
+
70
120
  ### Rules
71
121
 
72
122
  | Rule | Severity |
@@ -84,7 +134,7 @@ reporting success.
84
134
 
85
135
  ```bash
86
136
  bin/setup # install dependencies
87
- bundle exec rspec # run the test suite (61 examples)
137
+ bundle exec rspec # run the test suite (92 examples)
88
138
  bundle exec rubocop # lint
89
139
  bundle exec bundler-audit check --update # dependency vulnerability scan
90
140
  bin/console # interactive prompt
@@ -0,0 +1,57 @@
1
+ # Redact always in AI triage
2
+
3
+ - Status: accepted
4
+ - Date: 2026-07-26
5
+
6
+ ## Context
7
+
8
+ `scan` is rule-driven, so it reports what a regex matched, not what the match
9
+ means. `AKIAIOSFODNN7EXAMPLE` in `docs/example.md` and a live key in
10
+ `config/secrets.env` produce the same high-severity finding, and the gate blocks
11
+ on both. Classifying findings needs a model, which means deciding what a secret
12
+ scanner is allowed to transmit about the secrets it just found.
13
+
14
+ The tool's whole promise is that it runs before you publish something. Sending
15
+ credentials to a third-party API in order to ask whether they are credentials
16
+ would invert that promise, and "we only send it when you pass `--raw`" still
17
+ ships a foot-gun in a security tool.
18
+
19
+ ## Decision
20
+
21
+ Triage sends a finding's shape, never its value, with no opt-out.
22
+
23
+ - Every payload line is passed through `Redactor` first. A masked token is
24
+ replaced by its length and Shannon entropy, e.g. `[REDACTED chars=20
25
+ entropy=3.8]`.
26
+ - Redaction runs against the line re-read from disk, not the `Finding`'s
27
+ `preview`. Truncation can cut a secret mid-token so its own rule no longer
28
+ matches it, and the surviving fragment would ride along into the prompt.
29
+ - Every `Scanner` rule is applied, not just the one that produced the finding,
30
+ so a second secret sharing the line is masked too. Sensitive env-var values are
31
+ masked by key name, and a generic backstop masks any remaining run of 20+
32
+ characters from the secret alphabet.
33
+ - `--provider ollama` works with no configuration, so the redacted shape can stay
34
+ on the machine as well.
35
+ - Verdicts are advisory. The high-severity gate ignores them.
36
+
37
+ ## Consequences
38
+
39
+ Classification accuracy is lower than it would be with raw values: the model
40
+ judges from rule name, path, line number, entropy and surviving context. That
41
+ turned out to be enough on the cases that matter, because the strongest signal is
42
+ the path (`docs/`, `spec/`, `*.example`) rather than the characters.
43
+
44
+ The generic backstop is deliberately over-eager. It masks long URLs, hashes and
45
+ base64 in prose, which makes some prompts noisier than necessary. Safety over
46
+ prompt tidiness: the claim "nothing 20 characters or longer from the secret
47
+ alphabet survives" is only true if it applies to material no rule claimed.
48
+
49
+ Keeping the gate model-independent means a false `false_positive` cannot unblock
50
+ a publish. It also means triage does not reduce the work when the verdict is
51
+ right, it only tells you where to look first. Letting verdicts relax the gate is
52
+ the obvious next request; it stays refused.
53
+
54
+ ## Revisit when
55
+
56
+ Someone needs triage to change the exit code, or a provider offers an
57
+ attestable-local guarantee strong enough to reconsider what may be transmitted.
@@ -7,14 +7,22 @@ module Sharekit
7
7
  # Immutable value object for one scan hit. Data.define gives us structural
8
8
  # equality and Hash-pattern deconstruction (`in {severity:, rule:}`) for free —
9
9
  # a plain TS interface has neither.
10
- Finding = Data.define(:rule, :file, :line, :preview, :severity) do
10
+ #
11
+ # The triage fields default to nil, so a scan-only run builds a Finding the
12
+ # same way it always did, and `Triage` layers verdicts on with `#with` rather
13
+ # than mutating anything.
14
+ Finding = Data.define(:rule, :file, :line, :preview, :severity,
15
+ :verdict, :confidence, :rationale) do
11
16
  include Comparable
12
17
 
18
+ def initialize(verdict: nil, confidence: nil, rationale: nil, **) = super
19
+
13
20
  def <=>(other)
14
21
  SEVERITY_RANK.fetch(severity) <=> SEVERITY_RANK.fetch(other.severity)
15
22
  end
16
23
 
17
24
  def high? = severity == :high
25
+ def triaged? = !verdict.nil?
18
26
  end
19
27
  end
20
28
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scanner"
4
+
5
+ module Sharekit
6
+ module Cli
7
+ # Strips secret material out of a line before it can leave the machine.
8
+ #
9
+ # Redaction runs against the *original* line, never a Finding's truncated
10
+ # preview: truncation can cut a secret mid-token so that the rule pattern no
11
+ # longer matches it, which would let the surviving fragment through.
12
+ #
13
+ # A masked secret is replaced by its shape (length + Shannon entropy), which
14
+ # is what a classifier actually needs to tell a live key from EXAMPLE_KEY.
15
+ module Redactor
16
+ # Backstop for high-entropy material no Scanner rule claims. Runs last, so
17
+ # rule-labelled hits keep their own mask.
18
+ GENERIC_SECRET_PATTERN = %r{[A-Za-z0-9+/=_-]{20,}}
19
+ MASK_PREFIX = "[REDACTED"
20
+
21
+ module_function
22
+
23
+ # Masks every secret-shaped run in +line+: every Scanner rule, then
24
+ # sensitive env-var values, then the generic backstop. All rules run, not
25
+ # just the one that produced the finding, so a second secret sharing the
26
+ # line cannot ride along into a prompt.
27
+ def redact(line)
28
+ masked = Scanner::RULES.reduce(line) do |acc, rule|
29
+ acc.gsub(rule.pattern) { |hit| mask(hit) }
30
+ end
31
+ mask_env_values(masked).gsub(GENERIC_SECRET_PATTERN) { |hit| mask(hit) }
32
+ end
33
+
34
+ def mask(secret)
35
+ "#{MASK_PREFIX} chars=#{secret.length} entropy=#{format("%.1f", entropy(secret))}]"
36
+ end
37
+
38
+ # Shannon entropy in bits per character. Random tokens land near 4+ bits;
39
+ # placeholders like AKIAIOSFODNN7EXAMPLE sit noticeably lower.
40
+ def entropy(str)
41
+ return 0.0 if str.empty?
42
+
43
+ length = str.length.to_f
44
+ str.each_char.tally.values.sum do |count|
45
+ probability = count / length
46
+ -probability * Math.log2(probability)
47
+ end
48
+ end
49
+
50
+ # Scanner's ENV_VAR_PATTERN ends in `(.*)$`, which swallows the rest of the line,
51
+ # so the first assignment is the only one it ever examines. That is fine for
52
+ # reporting one finding per line, but as a redaction pass it leaks: in
53
+ # `PATH=/usr/bin API_KEY=short` the boring first key short-circuits the check and
54
+ # the real secret is never masked, while being too short for the generic
55
+ # backstop. Redaction therefore matches one assignment at a time. Quoted forms
56
+ # are listed explicitly because a bare `\S*` stops at the space in `KEY="a b"`.
57
+ ENV_ASSIGNMENT_PATTERN = /(?:^|\s)(?:export\s+)?([A-Z_]+)=("[^"]*"|'[^']*'|\S*)/
58
+
59
+ def mask_env_values(line)
60
+ line.gsub(ENV_ASSIGNMENT_PATTERN) do |hit|
61
+ key = Regexp.last_match(1)
62
+ value = Regexp.last_match(2)
63
+ next hit unless Scanner::SENSITIVE_KEY_PATTERN.match?(key)
64
+ next hit if value.empty? || value.start_with?(MASK_PREFIX)
65
+
66
+ hit.sub(value, mask(value))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -8,6 +8,10 @@ module Sharekit
8
8
  YELLOW = "\e[33m"
9
9
  RESET = "\e[0m"
10
10
 
11
+ VERDICT_LABELS = { "true_positive" => "likely real",
12
+ "false_positive" => "likely placeholder",
13
+ "uncertain" => "unclear" }.freeze
14
+
11
15
  module_function
12
16
 
13
17
  def report(findings, force: false)
@@ -16,19 +20,32 @@ module Sharekit
16
20
  return
17
21
  end
18
22
 
23
+ print_findings(findings)
24
+ gate!(findings, force:)
25
+ end
26
+
27
+ def print_findings(findings)
19
28
  puts "#{YELLOW}\n ⚠ Secret patterns detected:#{RESET}"
20
29
  findings.each { |finding| puts "#{YELLOW} #{format_line(finding)}#{RESET}" }
21
- puts "#{YELLOW}\n ⚠ Review and redact secrets before pushing to a public repository.\n#{RESET}"
22
-
23
- gate!(findings, force:)
30
+ puts "#{YELLOW}\n ⚠ Review and redact secrets before pushing to a public repository.#{RESET}"
31
+ if findings.any?(&:triaged?)
32
+ puts "#{YELLOW} ⚠ AI verdicts are advisory: the high-severity gate ignores them.#{RESET}"
33
+ end
34
+ puts ""
24
35
  end
25
36
 
26
37
  # Data.define objects deconstruct into a Hash for free, so `in` can match
27
38
  # directly on the fields we care about — no case/when + manual field access.
39
+ # The untriaged branch comes first, so a nil verdict never reaches the
40
+ # formatting that assumes one.
28
41
  def format_line(finding)
29
42
  case finding
30
- in { file:, line:, rule:, preview: }
43
+ in { verdict: nil, file:, line:, rule:, preview: }
31
44
  "#{file}:#{line} [#{rule}] #{preview}"
45
+ in { verdict:, confidence:, rationale:, file:, line:, rule:, preview: }
46
+ "#{file}:#{line} [#{rule}] #{preview}\n" \
47
+ " → #{VERDICT_LABELS.fetch(verdict, verdict)} " \
48
+ "(#{(confidence * 100).round}% confident) #{rationale}"
32
49
  end
33
50
  end
34
51
 
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "ruby_llm/schema"
5
+ require_relative "redactor"
6
+
7
+ module Sharekit
8
+ module Cli
9
+ # Classifies findings as real leaks or false positives with an LLM.
10
+ #
11
+ # SECURITY: the model never receives secret material, and there is no flag to
12
+ # opt out. Every payload line is rebuilt from the file on disk and passed
13
+ # through Redactor first, so what ships is the finding's *shape*: rule name,
14
+ # path, line number, length and entropy of the masked token, and whatever
15
+ # surrounding context survived redaction. Point --provider at Ollama to keep
16
+ # even that on the machine.
17
+ #
18
+ # Verdicts are advisory. Triage annotates output; it never relaxes the
19
+ # high-severity gate, because a model must not be what decides a live key
20
+ # isn't a key.
21
+ module Triage
22
+ VERDICTS = %w[true_positive false_positive uncertain].freeze
23
+
24
+ # Ollama's conventional local endpoint, so --provider ollama needs no setup.
25
+ OLLAMA_DEFAULT_BASE = "http://localhost:11434/v1"
26
+
27
+ RATIONALE_LIMIT = 120
28
+
29
+ SOURCE_UNAVAILABLE = "[source unavailable]"
30
+
31
+ # A missing API key or an unknown model id raise off a different branch of
32
+ # RubyLLM's hierarchy than provider/HTTP faults, and they are the two most
33
+ # likely failures here, so both branches are caught.
34
+ FAILURES = [RubyLLM::Error, RubyLLM::ConfigurationError,
35
+ RubyLLM::ModelNotFoundError, Faraday::Error].freeze
36
+
37
+ # Findings go over as an indexed list in one request, and verdicts are
38
+ # matched back by index. A verdict for an index the model skipped or
39
+ # invented is dropped rather than shifting every later finding's answer.
40
+ class Schema < RubyLLM::Schema
41
+ array :verdicts do
42
+ object do
43
+ integer :index, description: "index of the finding being judged"
44
+ string :verdict, enum: VERDICTS
45
+ number :confidence, description: "0.0 to 1.0"
46
+ string :rationale, description: "one sentence, at most #{RATIONALE_LIMIT} characters"
47
+ end
48
+ end
49
+ end
50
+
51
+ SYSTEM_PROMPT = <<~PROMPT
52
+ You triage secret-scanner findings for a tool that runs before someone
53
+ publishes a directory. For each finding decide whether it is a real leaked
54
+ credential (true_positive), a placeholder, example, test fixture or
55
+ documentation sample (false_positive), or genuinely unclear (uncertain).
56
+
57
+ Secret values have been redacted before reaching you. A masked token shows
58
+ only its length and Shannon entropy, as
59
+ "[REDACTED chars=20 entropy=3.8]". Judge from the rule name, the file path,
60
+ the line number, and the surrounding context. High entropy and a config or
61
+ source path suggest a real credential; low entropy, or paths like
62
+ spec/, test/, fixtures/, docs/, README, *.example, *.sample suggest not.
63
+
64
+ Never claim to have read the secret. Reply for every index you are given.
65
+ PROMPT
66
+
67
+ module_function
68
+
69
+ # Returns a new array of findings, each with triage fields filled where the
70
+ # model answered. Findings the model skipped come back untouched.
71
+ def call(findings, **chat_options)
72
+ return findings if findings.empty?
73
+
74
+ verdicts = request(findings, **chat_options)
75
+ findings.each_with_index.map do |finding, index|
76
+ verdict = verdicts[index]
77
+ verdict ? finding.with(**verdict) : finding
78
+ end
79
+ end
80
+
81
+ def request(findings, **chat_options)
82
+ configure
83
+ chat = RubyLLM.chat(**chat_options.compact)
84
+ .with_instructions(SYSTEM_PROMPT)
85
+ .with_schema(Schema)
86
+ # On 1.16 a schema-backed reply arrives parsed on #content (there is no
87
+ # #parsed), and stays a String if the provider returned unparseable JSON.
88
+ parse(chat.ask(prompt_for(findings)).content, findings.size)
89
+ rescue *FAILURES => e
90
+ raise Error, "AI triage failed: #{e.class}: #{e.message}"
91
+ end
92
+
93
+ # RubyLLM reads no provider credentials from the environment on its own,
94
+ # and a CLI has nowhere else to get them.
95
+ def configure
96
+ RubyLLM.configure do |config|
97
+ config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
98
+ config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
99
+ config.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", OLLAMA_DEFAULT_BASE)
100
+ end
101
+ end
102
+
103
+ # Rebuilds each finding's line from disk so redaction sees the whole line,
104
+ # never the already-truncated preview.
105
+ def prompt_for(findings)
106
+ lines = Hash.new { |cache, path| cache[path] = read_lines(path) }
107
+
108
+ findings.each_with_index.map { |finding, index| entry(finding, index, lines) }.join("\n")
109
+ end
110
+
111
+ # There is deliberately no fallback to `finding.preview` when the file cannot be
112
+ # read: the preview is truncated, and a fragment cut mid-token matches neither its
113
+ # own rule nor the generic backstop, which is the exact leak reading from disk
114
+ # exists to prevent. Losing one finding's context is the cheaper failure. The path
115
+ # is redacted too, so a token embedded in a filename cannot ride along.
116
+ def entry(finding, index, lines)
117
+ raw = lines[finding.file]&.[](finding.line - 1)
118
+ <<~ENTRY
119
+ #{index}. rule=#{finding.rule} severity=#{finding.severity}
120
+ location=#{Redactor.redact(finding.file)}:#{finding.line}
121
+ context=#{raw ? Redactor.redact(raw.chomp) : SOURCE_UNAVAILABLE}
122
+ ENTRY
123
+ end
124
+
125
+ def read_lines(path)
126
+ File.readlines(path, encoding: "UTF-8")
127
+ rescue ArgumentError, SystemCallError
128
+ nil
129
+ end
130
+
131
+ def parse(content, count)
132
+ return {} unless content.is_a?(Hash)
133
+
134
+ Array(content["verdicts"]).each_with_object({}) do |row, acc|
135
+ index = verdict_index(row, count)
136
+ next unless index
137
+
138
+ acc[index] = { verdict: row["verdict"],
139
+ confidence: row["confidence"].to_f.clamp(0.0, 1.0),
140
+ rationale: rationale(row["rationale"]) }
141
+ end
142
+ end
143
+
144
+ # Models routinely blow past the length the schema asks for, and these land
145
+ # in a terminal one line per finding, so the limit is enforced here too.
146
+ def rationale(raw)
147
+ text = raw.to_s.strip
148
+ return text if text.length <= RATIONALE_LIMIT
149
+
150
+ "#{text[0, RATIONALE_LIMIT - 1]}…"
151
+ end
152
+
153
+ # Only an in-range index paired with a label we asked for is usable; anything
154
+ # else is dropped so it cannot shift another finding's verdict.
155
+ def verdict_index(row, count)
156
+ return unless row.is_a?(Hash)
157
+
158
+ index = row["index"]
159
+ return unless index.is_a?(Integer) && index.between?(0, count - 1)
160
+ return unless VERDICTS.include?(row["verdict"])
161
+
162
+ index
163
+ end
164
+ end
165
+ end
166
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sharekit
4
4
  module Cli
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/sharekit/cli.rb CHANGED
@@ -9,9 +9,15 @@ require_relative "cli/file_lister"
9
9
  require_relative "cli/init"
10
10
 
11
11
  module Sharekit
12
+ # Command-line entry points for scanning a directory for leaked secrets and
13
+ # scaffolding a publishable profile.
12
14
  module Cli
13
15
  class Error < StandardError; end
14
16
 
17
+ # Loading ruby_llm pulls in a provider stack that a plain `scan` never needs,
18
+ # so Triage is resolved on first reference instead of at boot.
19
+ autoload :Triage, "sharekit/cli/triage"
20
+
15
21
  # Thor gives us subcommand dispatch, flag parsing, and --help for free —
16
22
  # the TS original hand-parses process.argv/flags itself.
17
23
  class App < Thor
@@ -22,14 +28,23 @@ module Sharekit
22
28
  type: :boolean,
23
29
  default: false,
24
30
  desc: "exit 0 even if high-severity findings detected"
31
+ method_option :ai_triage,
32
+ type: :boolean,
33
+ default: false,
34
+ desc: "label findings true/false positive with an LLM (secret values are redacted first)"
35
+ method_option :model,
36
+ type: :string,
37
+ desc: "model id for --ai-triage (default: RubyLLM's configured default)"
38
+ method_option :provider,
39
+ type: :string,
40
+ desc: "provider for --ai-triage, e.g. anthropic, openai, ollama"
41
+ method_option :assume_model_exists,
42
+ type: :boolean,
43
+ default: false,
44
+ desc: "skip model-registry validation, needed for local Ollama models"
25
45
  def scan(dir = ".")
26
- findings = FileLister.list(dir).flat_map do |path|
27
- Scanner.scan(File.read(path, encoding: "UTF-8"), file: path).to_a
28
- rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e
29
- puts " ~ Skipped #{path}: #{e.class}"
30
- []
31
- end
32
-
46
+ findings = collect_findings(dir)
47
+ findings = triage(findings) if options[:ai_triage]
33
48
  Reporter.report(findings, force: options[:force])
34
49
  rescue Error => e
35
50
  warn e.message
@@ -55,6 +70,31 @@ module Sharekit
55
70
  end
56
71
 
57
72
  no_commands do
73
+ def collect_findings(dir)
74
+ FileLister.list(dir).flat_map do |path|
75
+ Scanner.scan(File.read(path, encoding: "UTF-8"), file: path).to_a
76
+ rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e
77
+ puts " ~ Skipped #{path}: #{e.class}"
78
+ []
79
+ end
80
+ end
81
+
82
+ def triage(findings)
83
+ return findings if findings.empty?
84
+
85
+ puts "\n … AI triage: #{findings.size} finding(s), secret values redacted before sending"
86
+ Triage.call(findings,
87
+ model: options[:model],
88
+ provider: options[:provider]&.to_sym,
89
+ assume_model_exists: options[:assume_model_exists])
90
+ rescue LoadError => e
91
+ # ruby_llm is optional: a scan-only install should not have to carry a
92
+ # provider stack. Autoloading Triage is what surfaces its absence here.
93
+ raise Error, "--ai-triage needs the ruby_llm gem, which is not installed. " \
94
+ "Install it with `gem install ruby_llm` (or add it to your Gemfile). " \
95
+ "(#{e.message})"
96
+ end
97
+
58
98
  def print_init_summary(result)
59
99
  result.created.each { |path| puts " + #{path}" }
60
100
  puts "\n ✓ Created profile at #{result.profile_dir}\n\n"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sharekit-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Santana
@@ -38,13 +38,16 @@ files:
38
38
  - LICENSE.txt
39
39
  - README.md
40
40
  - Rakefile
41
+ - docs/adr/2026-07-26-redact-always-ai-triage.md
41
42
  - exe/sharekit-cli
42
43
  - lib/sharekit/cli.rb
43
44
  - lib/sharekit/cli/file_lister.rb
44
45
  - lib/sharekit/cli/finding.rb
45
46
  - lib/sharekit/cli/init.rb
47
+ - lib/sharekit/cli/redactor.rb
46
48
  - lib/sharekit/cli/reporter.rb
47
49
  - lib/sharekit/cli/scanner.rb
50
+ - lib/sharekit/cli/triage.rb
48
51
  - lib/sharekit/cli/version.rb
49
52
  - sig/sharekit/cli.rbs
50
53
  homepage: https://github.com/LucasSantana-Dev/sharekit-cli