sentinel-ci 0.2.0 → 1.0.1

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: 94ed1d7a9cf81b5377e335791dca1762b33c6871403c533939e8237af3358f35
4
- data.tar.gz: f737935da1f9a2ddc97d5a3b81a86b9fddd36280fc124f664a49a3ef1e60f402
3
+ metadata.gz: d80229adb1733c9bd79d6770c46d0b117702fc4ff1fe71e99d1afa5fe3b78ffe
4
+ data.tar.gz: 2592ada1faa0fbf3917431c6baf4b341ab34a5d9d424a7caee20ec7a701e2779
5
5
  SHA512:
6
- metadata.gz: 1280505b93699c79b3363a7c252a577ccc15dd376a136fac8d50f848774764028fb0c4e8eccd17069ddeb0b32625aae6d321ab3b001bf731a43408abd4d935f9
7
- data.tar.gz: 1d8ba8a0af316b1ae523ca49fe6de2baa27898aec5355c04b5da09cc7d0779356d85361455d6a76dc19c43a618459549d518e178c3a4320025d73c6407e5280b
6
+ metadata.gz: ccd23b049b0582c04b90baa5a9112197fb7c0407078edfd043c3ddcf93857a9adade4a9c83a1a46c6fe9dcc2ea1cd2c133664dc3c6f19a0a79617fe741dc163d
7
+ data.tar.gz: da385d744d1897516f8422bc80701076c972a82014175ba1a6c0b4eac2b86852169b5635b7ad30a25008b45fe903dfcf5346a89e3f3f2f275fdca32454d51f00
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1 (2026-05-17)
4
+
5
+ - Smart clone auth: try HTTPS, SSH, then gh token — no manual GITHUB_TOKEN needed for private repos
6
+
7
+ ## 1.0.0 (2026-05-16)
8
+
9
+ ### New Features
10
+ - MCP server for AI coding agents (sentinel mcp)
11
+ - Remote fix with PR creation (sentinel fix owner/repo)
12
+ - Policy engine wired into GitHub Action
13
+
14
+ ### Security Fixes
15
+ - Git credential leakage prevention in action fix mode
16
+ - Prompt injection mitigation in AI fix (XML fences + UNTRUSTED warning)
17
+ - Annotation injection sanitization
18
+ - Tempfile race condition in policy loading
19
+
20
+ ### Test Coverage
21
+ - 459 tests, 1358 assertions
22
+ - Added: ShaResolver, RuleEngine, bot state, formatter, CLI fix tests
23
+ - All 28 rules have test coverage
24
+
3
25
  ## 0.2.0 (2026-05-16)
4
26
 
5
27
  ### New Rules (7)
data/README.md CHANGED
@@ -200,6 +200,27 @@ ruby bot/scanner_bot.rb --pattern shell-injection --dry-run
200
200
  - Opt-out support, clear bot identity
201
201
  - Runs as daily cron via GitHub Actions
202
202
 
203
+ ## MCP Server
204
+
205
+ Use Sentinel as a tool in AI coding agents (Claude Code, Copilot, Cursor):
206
+
207
+ ```bash
208
+ # Start the MCP server
209
+ sentinel mcp
210
+
211
+ # Configure in Claude Code (~/.claude.json)
212
+ {
213
+ "mcpServers": {
214
+ "sentinel": {
215
+ "command": "sentinel",
216
+ "args": ["mcp"]
217
+ }
218
+ }
219
+ }
220
+ ```
221
+
222
+ Three tools available: `sentinel_scan`, `sentinel_deps`, `sentinel_fix`.
223
+
203
224
  ## Supply Chain Analysis
204
225
 
205
226
  Map third-party action dependencies with risk scoring:
@@ -259,6 +280,9 @@ lib/
259
280
  rules/
260
281
  base.rb # abstract rule interface
261
282
  *.rb # one file per rule (27 rules)
283
+ mcp/
284
+ server.rb # MCP server for AI coding agents
285
+ claude-code-config.json # example configuration for Claude Code
262
286
  bot/
263
287
  scanner_bot.rb # PR bot orchestrator
264
288
  search.rb # GitHub Code Search client
data/bin/sentinel CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "../lib/version"
4
4
 
5
- SUBCOMMANDS = %w[scan fix deps bot hook version help].freeze
5
+ SUBCOMMANDS = %w[scan fix deps bot hook mcp version help].freeze
6
6
 
7
7
  HELP_TEXT = <<~HELP
8
8
  Usage: sentinel <command> [options] [args]
@@ -13,6 +13,7 @@ HELP_TEXT = <<~HELP
13
13
  deps [REPO] Map third-party action dependencies and risk factors
14
14
  bot Run the PR bot
15
15
  hook [install|uninstall|run] Pre-commit hook for workflow file scanning
16
+ mcp Start MCP server (for AI coding agents)
16
17
  version Print version
17
18
  help Show this help message
18
19
 
@@ -58,6 +59,9 @@ when "bot"
58
59
  require_relative "../lib/cli/bot"
59
60
  when "hook"
60
61
  require_relative "../lib/cli/hook"
62
+ when "mcp"
63
+ require_relative "../mcp/server"
64
+ McpServer.new.run
61
65
  when "version"
62
66
  puts "sentinel #{Sentinel::VERSION}"
63
67
  when "help"
data/lib/clone_client.rb CHANGED
@@ -19,25 +19,16 @@ class CloneClient
19
19
 
20
20
  @tmpdir = Dir.mktmpdir("sentinel-")
21
21
 
22
- # Shallow sparse clone — only .github/ directory
23
- success = system(
24
- "git", "clone", "--depth", "1", "--filter=blob:none", "--sparse",
25
- "https://github.com/#{repo}.git", @tmpdir,
26
- [:out, :err] => File::NULL
27
- )
22
+ success = try_clone(repo)
28
23
 
29
24
  unless success
30
25
  $stderr.puts ""
31
26
  $stderr.puts "ERROR: Could not access #{repo}"
32
27
  $stderr.puts ""
33
- $stderr.puts "This repo may be private. To scan private repos:"
34
- $stderr.puts ""
35
- $stderr.puts " export GITHUB_TOKEN=$(gh auth token)"
36
- $stderr.puts " sentinel scan #{repo}"
37
- $stderr.puts ""
38
- $stderr.puts "Or pass a token directly:"
39
- $stderr.puts ""
40
- $stderr.puts " sentinel scan --token ghp_xxx #{repo}"
28
+ $stderr.puts "If this is a private repo, make sure git can authenticate:"
29
+ $stderr.puts " - SSH key configured (git clone git@github.com:#{repo})"
30
+ $stderr.puts " - Or: gh auth login"
31
+ $stderr.puts " - Or: export GITHUB_TOKEN=$(gh auth token)"
41
32
  $stderr.puts ""
42
33
  exit 2
43
34
  end
@@ -63,4 +54,40 @@ class CloneClient
63
54
  def cleanup
64
55
  FileUtils.rm_rf(@tmpdir) if @tmpdir
65
56
  end
57
+
58
+ private
59
+
60
+ CLONE_ARGS = %w[--depth 1 --filter=blob:none --sparse].freeze
61
+
62
+ def try_clone(repo)
63
+ # 1. HTTPS — works for public repos and if credential helper is configured
64
+ return true if try_url("https://github.com/#{repo}.git")
65
+
66
+ # 2. SSH — works if SSH key is configured
67
+ return true if try_url("git@github.com:#{repo}.git")
68
+
69
+ # 3. HTTPS with gh auth token — works if gh CLI is authenticated
70
+ token = detect_gh_token
71
+ if token
72
+ return true if try_url("https://x-access-token:#{token}@github.com/#{repo}.git")
73
+ end
74
+
75
+ false
76
+ end
77
+
78
+ def try_url(url)
79
+ FileUtils.rm_rf(Dir.children(@tmpdir)) if @tmpdir && File.directory?(@tmpdir)
80
+ system("git", "clone", *CLONE_ARGS, url, @tmpdir, [:out, :err] => File::NULL)
81
+ end
82
+
83
+ def detect_gh_token
84
+ return ENV["GITHUB_TOKEN"] if ENV["GITHUB_TOKEN"]
85
+
86
+ gh_path = `which gh 2>/dev/null`.strip
87
+ return nil if gh_path.empty?
88
+ return nil unless system("gh", "auth", "status", [:out, :err] => File::NULL)
89
+
90
+ token = `gh auth token 2>/dev/null`.strip
91
+ token.empty? ? nil : token
92
+ end
66
93
  end
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sentinel
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.1"
3
3
  end
@@ -0,0 +1,17 @@
1
+ {
2
+ "_comment": "Add one of these configurations to ~/.claude.json or .claude/settings.json",
3
+
4
+ "mcpServers": {
5
+ "sentinel-gem": {
6
+ "_comment": "If installed via: gem install sentinel-ci",
7
+ "command": "sentinel",
8
+ "args": ["mcp"]
9
+ },
10
+ "sentinel-local": {
11
+ "_comment": "If running from a local clone",
12
+ "command": "ruby",
13
+ "args": ["mcp/server.rb"],
14
+ "cwd": "/path/to/sentinel"
15
+ }
16
+ }
17
+ }
data/mcp/server.rb ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "json"
4
+ $LOAD_PATH.unshift(File.join(__dir__, "..", "lib"))
5
+ require "scanner"
6
+ require "supply_chain"
7
+ require "version"
8
+
9
+ class McpServer
10
+ def initialize
11
+ @running = true
12
+ end
13
+
14
+ def run
15
+ $stderr.puts "Sentinel MCP server v#{Sentinel::VERSION} starting..."
16
+ while @running && (line = $stdin.gets)
17
+ line = line.strip
18
+ next if line.empty?
19
+
20
+ begin
21
+ request = JSON.parse(line)
22
+ response = handle(request)
23
+ write_response(response) if response
24
+ rescue JSON::ParserError => e
25
+ write_response(error_response(nil, -32700, "Parse error: #{e.message}"))
26
+ rescue => e
27
+ $stderr.puts "Error: #{e.message}"
28
+ write_response(error_response(request&.dig("id"), -32603, e.message))
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle(request)
36
+ id = request["id"]
37
+ method = request["method"]
38
+
39
+ case method
40
+ when "initialize"
41
+ result_response(id, {
42
+ protocolVersion: "2024-11-05",
43
+ capabilities: { tools: {} },
44
+ serverInfo: { name: "sentinel", version: Sentinel::VERSION }
45
+ })
46
+ when "notifications/initialized"
47
+ nil # no response for notifications
48
+ when "tools/list"
49
+ result_response(id, { tools: tool_definitions })
50
+ when "tools/call"
51
+ tool_name = request.dig("params", "name")
52
+ args = request.dig("params", "arguments") || {}
53
+ execute_tool(id, tool_name, args)
54
+ when "ping"
55
+ result_response(id, {})
56
+ else
57
+ error_response(id, -32601, "Method not found: #{method}")
58
+ end
59
+ end
60
+
61
+ def tool_definitions
62
+ [
63
+ {
64
+ name: "sentinel_scan",
65
+ description: "Scan a GitHub repo or local path for CI/CD security vulnerabilities. Returns findings with severity, rule, file, line, and fix recommendations.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ target: { type: "string", description: "GitHub repo (owner/repo) or local path" },
70
+ severity: { type: "string", enum: ["critical", "high", "medium", "low"], description: "Minimum severity threshold (default: low)" },
71
+ format: { type: "string", enum: ["json", "sarif"], description: "Output format (default: json)" }
72
+ },
73
+ required: ["target"]
74
+ }
75
+ },
76
+ {
77
+ name: "sentinel_deps",
78
+ description: "Analyze third-party action dependencies for a repo with risk scoring. Shows which actions you depend on, their maintainers, stars, and risk factors.",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ target: { type: "string", description: "GitHub repo (owner/repo) or local path" }
83
+ },
84
+ required: ["target"]
85
+ }
86
+ },
87
+ {
88
+ name: "sentinel_fix",
89
+ description: "Auto-fix security findings in workflow files. Returns diffs of what would be changed. Supports 6 mechanical fixes (unpinned actions, shell injection, persist-credentials, missing permissions, missing timeouts, workflow dispatch injection).",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ target: { type: "string", description: "Local path to scan and fix" },
94
+ dry_run: { type: "boolean", description: "If true, return diffs without writing files (default: true)" }
95
+ },
96
+ required: ["target"]
97
+ }
98
+ }
99
+ ]
100
+ end
101
+
102
+ def execute_tool(id, tool_name, args)
103
+ case tool_name
104
+ when "sentinel_scan"
105
+ do_scan(id, args)
106
+ when "sentinel_deps"
107
+ do_deps(id, args)
108
+ when "sentinel_fix"
109
+ do_fix(id, args)
110
+ else
111
+ error_response(id, -32602, "Unknown tool: #{tool_name}")
112
+ end
113
+ end
114
+
115
+ def do_scan(id, args)
116
+ target = args["target"]
117
+ severity = (args["severity"] || "low").to_sym
118
+ format = args["format"] || "json"
119
+
120
+ client = if File.directory?(target)
121
+ LocalClient.new(target)
122
+ else
123
+ token = ENV["GITHUB_TOKEN"]
124
+ if token
125
+ GitHubClient.new(token: token)
126
+ else
127
+ CloneClient.new
128
+ end
129
+ end
130
+
131
+ formatter = format == "sarif" ? Formatter::Sarif.new : Formatter::Json.new
132
+ scanner = Scanner.new(client: client, formatter: formatter, min_severity: severity)
133
+
134
+ begin
135
+ result = scanner.scan(target)
136
+ text_response(id, result[:output])
137
+ ensure
138
+ client.cleanup if client.respond_to?(:cleanup)
139
+ end
140
+ end
141
+
142
+ def do_deps(id, args)
143
+ target = args["target"]
144
+ token = ENV["GITHUB_TOKEN"]
145
+
146
+ workflows = if File.directory?(target)
147
+ LocalClient.new(target).fetch_workflows(target).map { |w|
148
+ Workflow.new(filename: w[:filename], content: w[:content])
149
+ }
150
+ else
151
+ client = token ? GitHubClient.new(token: token) : CloneClient.new
152
+ begin
153
+ raw = client.fetch_workflows(target)
154
+ raw.map { |w| Workflow.new(filename: w[:filename], content: w[:content]) }
155
+ ensure
156
+ client.cleanup if client.respond_to?(:cleanup)
157
+ end
158
+ end
159
+
160
+ chain = SupplyChain.new(token: token)
161
+ actions = chain.analyze(workflows)
162
+ text_response(id, JSON.pretty_generate(actions))
163
+ end
164
+
165
+ def do_fix(id, args)
166
+ target = args["target"]
167
+ dry_run = args.fetch("dry_run", true)
168
+
169
+ unless File.directory?(target)
170
+ return error_response(id, -32602, "sentinel_fix requires a local path, not a remote repo")
171
+ end
172
+
173
+ # Scan first
174
+ client = LocalClient.new(target)
175
+ formatter = Formatter::Json.new
176
+ scanner = Scanner.new(client: client, formatter: formatter, min_severity: :low)
177
+ result = scanner.scan(target)
178
+
179
+ findings = JSON.parse(result[:output])["findings"]
180
+ fixable = findings.select { |f|
181
+ AutoFix.can_fix?(Finding.new(
182
+ rule: f["rule"], severity: f["severity"].to_sym,
183
+ file: f["file"], line: f["line"],
184
+ code: f["code"], message: f["message"], fix: f["fix"]
185
+ ))
186
+ }
187
+
188
+ if fixable.empty?
189
+ return text_response(id, "No auto-fixable findings found.")
190
+ end
191
+
192
+ # Apply fixes
193
+ sha_resolver = ShaResolver.new
194
+ diffs = []
195
+
196
+ fixable.group_by { |f| f["file"] }.each do |file, file_findings|
197
+ path = File.join(target, ".github", "workflows", file)
198
+ next unless File.exist?(path)
199
+
200
+ content = File.read(path)
201
+ original = content.dup
202
+
203
+ file_findings.sort_by { |f| -(f["line"] || 0) }.each do |raw|
204
+ finding = Finding.new(
205
+ rule: raw["rule"], severity: raw["severity"].to_sym,
206
+ file: raw["file"], line: raw["line"],
207
+ code: raw["code"], message: raw["message"], fix: raw["fix"]
208
+ )
209
+ patched = AutoFix.apply(finding, content, sha_resolver: sha_resolver)
210
+ content = patched if patched && patched != content
211
+ end
212
+
213
+ if content != original
214
+ diffs << "--- .github/workflows/#{file}\n+++ .github/workflows/#{file} (fixed)\n#{content}"
215
+ File.write(path, content) unless dry_run
216
+ end
217
+ end
218
+
219
+ summary = dry_run ? "Dry run -- #{diffs.length} files would be fixed" : "#{diffs.length} files fixed"
220
+ text_response(id, "#{summary}\n\n#{diffs.join("\n\n")}")
221
+ end
222
+
223
+ def result_response(id, result)
224
+ { jsonrpc: "2.0", id: id, result: result }
225
+ end
226
+
227
+ def text_response(id, text)
228
+ result_response(id, { content: [{ type: "text", text: text }] })
229
+ end
230
+
231
+ def error_response(id, code, message)
232
+ { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
233
+ end
234
+
235
+ def write_response(response)
236
+ json = JSON.generate(response)
237
+ $stdout.puts(json)
238
+ $stdout.flush
239
+ end
240
+ end
241
+
242
+ McpServer.new.run if __FILE__ == $0
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sentinel-ci
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Ritter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-16 00:00:00.000000000 Z
11
+ date: 2026-05-17 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Scan GitHub Actions workflows for 28 security vulnerabilities. SHA pinning,
14
14
  shell injection, credential exposure, dangerous triggers. Optional AI-powered remediation
@@ -76,6 +76,8 @@ files:
76
76
  - lib/supply_chain.rb
77
77
  - lib/version.rb
78
78
  - lib/workflow.rb
79
+ - mcp/claude-code-config.json
80
+ - mcp/server.rb
79
81
  homepage: https://sentinel.copilotkit.dev
80
82
  licenses:
81
83
  - MIT