sentinel-ci 0.2.0 → 1.0.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: 94ed1d7a9cf81b5377e335791dca1762b33c6871403c533939e8237af3358f35
4
- data.tar.gz: f737935da1f9a2ddc97d5a3b81a86b9fddd36280fc124f664a49a3ef1e60f402
3
+ metadata.gz: 4afefad30650b21315fce711323ed0d39a2046a472e5e62b081d2b4b83476cf7
4
+ data.tar.gz: 36fa211355533de0fe72265ef44878b0ad14604f0d309a7b00f50e55ebd59517
5
5
  SHA512:
6
- metadata.gz: 1280505b93699c79b3363a7c252a577ccc15dd376a136fac8d50f848774764028fb0c4e8eccd17069ddeb0b32625aae6d321ab3b001bf731a43408abd4d935f9
7
- data.tar.gz: 1d8ba8a0af316b1ae523ca49fe6de2baa27898aec5355c04b5da09cc7d0779356d85361455d6a76dc19c43a618459549d518e178c3a4320025d73c6407e5280b
6
+ metadata.gz: 70cbe3787c1ddd1e7227beac14373b0826ea108c5b985cf16f12159c55be3f41076341d22fed0bd6845d4e75eae1fd1fd8ae16ceef31b0966b1b9cd95aaa0160
7
+ data.tar.gz: edf35ebd84e3c162e82be1ad57b04b0f6d25ad96f3c50201db57d49847bea87b507ef7188efd30c65a62b50e248dcf1405c6cf6aff761d5601394ea035aca867
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0 (2026-05-16)
4
+
5
+ ### New Features
6
+ - MCP server for AI coding agents (sentinel mcp)
7
+ - Remote fix with PR creation (sentinel fix owner/repo)
8
+ - Policy engine wired into GitHub Action
9
+
10
+ ### Security Fixes
11
+ - Git credential leakage prevention in action fix mode
12
+ - Prompt injection mitigation in AI fix (XML fences + UNTRUSTED warning)
13
+ - Annotation injection sanitization
14
+ - Tempfile race condition in policy loading
15
+
16
+ ### Test Coverage
17
+ - 459 tests, 1358 assertions
18
+ - Added: ShaResolver, RuleEngine, bot state, formatter, CLI fix tests
19
+ - All 28 rules have test coverage
20
+
3
21
  ## 0.2.0 (2026-05-16)
4
22
 
5
23
  ### 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/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sentinel
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
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,7 +1,7 @@
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Ritter
@@ -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