seamshield 0.1.3 → 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.
package/README.md CHANGED
@@ -1,20 +1,21 @@
1
1
  # SeamShield
2
2
 
3
- Local security scanner for AI-built JavaScript and TypeScript apps.
3
+ Local access-lane security scanner for AI-built JavaScript and TypeScript apps.
4
4
 
5
- SeamShield finds common flaws that AI-generated apps often ship: committed secrets, client-exposed server keys, client-only auth, open platform rules, unsafe agent config, and dependency supply-chain risks.
5
+ SeamShield maps who or what can reach sensitive assets before you ship:
6
+ `Actor -> Lane -> Asset -> Permission -> Condition -> Risk`.
6
7
 
7
8
  ## Install
8
9
 
9
10
  ```bash
10
- npx seamshield scan .
11
+ npx seamshield ship .
11
12
  ```
12
13
 
13
14
  Or install globally:
14
15
 
15
16
  ```bash
16
17
  npm install -g seamshield
17
- seamshield scan .
18
+ seamshield ship .
18
19
  ```
19
20
 
20
21
  Requires Node.js 20 or newer.
@@ -22,14 +23,38 @@ Requires Node.js 20 or newer.
22
23
  ## Commands
23
24
 
24
25
  ```bash
25
- seamshield scan .
26
- seamshield fix-plan .
26
+ seamshield ship .
27
+ seamshield access . --format table
28
+ seamshield access . --format json
29
+ seamshield scan . --offline
30
+ seamshield fix-plan . --agent codex
31
+ seamshield triage . --rule ss/auth/client-only-guard
27
32
  seamshield agent-context . --claude
28
33
  seamshield agent-context . --cursor
29
34
  seamshield guard install .
30
- seamshield guard check
35
+ seamshield learn
36
+ ```
37
+
38
+ ## Ship Verdict
39
+
40
+ ```bash
41
+ seamshield ship .
42
+ ```
43
+
44
+ Runs locally and prints `SAFE TO SHIP` only when there are no block or high
45
+ access-lane risks. Use this before deploys.
46
+
47
+ ## Access Map
48
+
49
+ ```bash
50
+ seamshield access . --format json
31
51
  ```
32
52
 
53
+ Outputs normalized access lanes while preserving provider-specific evidence.
54
+ Supported surfaces include Next.js/API routes, Supabase, Firebase/Firestore,
55
+ Convex, Vercel/Coolify/self-hosted deploy config, generic Node servers, AI
56
+ agent config, and package supply-chain risks.
57
+
33
58
  ## Scan
34
59
 
35
60
  ```bash
@@ -42,40 +67,49 @@ seamshield scan . --offline
42
67
 
43
68
  Exit codes:
44
69
 
45
- - `0` - no findings at or above the selected `--fail-on` threshold.
46
- - `1` - findings at or above the threshold.
70
+ - `0` - no findings at or above the selected threshold.
71
+ - `1` - findings at or above the selected threshold.
47
72
  - `2` - CLI usage or scanner failure.
48
73
 
49
74
  `--offline` disables npm registry and OSV checks. Static rules still run.
50
75
 
51
- ## Fix Plans
76
+ ## Triage
52
77
 
53
78
  ```bash
54
- seamshield fix-plan .
79
+ seamshield triage . --rule ss/auth/client-only-guard
55
80
  ```
56
81
 
57
- Writes `.seamshield/fix-plan.json` with redacted findings and agent-ready remediation prompts.
82
+ Persists current false-positive decisions into `.seamshield/config.yaml` as
83
+ exact rule/file/line suppressions. Block findings are not triaged unless
84
+ `--include-block` is provided.
58
85
 
59
- ## Agent Context
86
+ ## Fix Plans
60
87
 
61
88
  ```bash
62
- seamshield agent-context . --claude
63
- seamshield agent-context . --cursor
89
+ seamshield fix-plan . --agent claude
90
+ seamshield fix-plan . --agent cursor
91
+ seamshield fix-plan . --agent codex
92
+ seamshield fix-plan . --agent generic
64
93
  ```
65
94
 
66
- Claude writes or updates `CLAUDE.md`. Cursor writes `.cursor/rules/seamshield.mdc`.
95
+ Writes `.seamshield/fix-plan.json` and a Markdown plan under
96
+ `.seamshield/fix-plans/` with redacted findings and provider-aware prompts.
67
97
 
68
- ## Claude Code Guard
98
+ ## Agent Guard
69
99
 
70
100
  ```bash
101
+ seamshield agent-context . --claude
102
+ seamshield agent-context . --cursor
71
103
  seamshield guard install .
72
104
  ```
73
105
 
74
- Installs a Claude Code `PreToolUse` hook for `Write`, `Edit`, `MultiEdit`, and `Bash`.
75
-
76
- The guard denies block-severity edits such as hardcoded provider keys, service-role keys, private keys, committed dotenv files, open Firebase rules, or RLS disablement. Bash checks deny obvious dangerous commands such as `git add .env*`, `curl ... | sh`, and installs of npm packages that do not resolve.
106
+ `agent-context` writes agent instructions. `guard install` adds a Claude Code
107
+ `PreToolUse` hook that blocks high-confidence risky edits such as committed
108
+ dotenv files, exposed server secrets, public database/storage writes, unsafe
109
+ `.env` edits, dangerous shell installs, and obvious privileged route exposure.
77
110
 
78
- Guard behavior is fail-open: if the hook errors, it allows the tool call and appends diagnostics to `.seamshield/guard.log`.
111
+ Guard behavior is fail-open: if the hook errors, it allows the tool call and
112
+ appends diagnostics to `.seamshield/guard.log`.
79
113
 
80
114
  ## Configuration
81
115
 
@@ -84,6 +118,11 @@ Create `.seamshield/config.yaml`:
84
118
  ```yaml
85
119
  ignore:
86
120
  - vendored/**
121
+ suppress:
122
+ - rule: ss/auth/client-only-guard
123
+ file: app/dashboard/page.tsx
124
+ line: 42
125
+ reason: server-side route enforces auth
87
126
  rules:
88
127
  disable:
89
128
  - ss/auth/client-only-guard
@@ -100,19 +139,9 @@ const fixtureKey = "sk_live_test_fixture_only";
100
139
 
101
140
  SeamShield runs locally. Static scanning does not transmit source code.
102
141
 
103
- Network dependency checks send package names and versions to the npm registry and OSV. Use `--offline` to disable those checks.
104
-
105
- Secret evidence is redacted before findings, JSON, SARIF, and fix plans are emitted.
106
-
107
- ## Public Rule Coverage
108
-
109
- - Secrets and client exposure
110
- - Next.js auth footguns
111
- - Supabase, Convex, and Firebase platform mistakes
112
- - Dependency lockfile, pinning, hallucinated-package, and OSV vulnerability checks
113
- - Agent config secrets and overbroad permissions
114
-
115
- ## Links
142
+ Network dependency checks send package names and versions to the npm registry
143
+ and OSV. Use `--offline` or avoid `--online` on `ship` and `access` to keep the
144
+ run fully local.
116
145
 
117
- - Repository: https://github.com/KaraboGerald/SeamShield
118
- - Issues: https://github.com/KaraboGerald/SeamShield/issues
146
+ Secret evidence is redacted before findings, JSON, SARIF, and fix plans are
147
+ emitted.
package/dist/index.js CHANGED
@@ -99,14 +99,17 @@ var require_picocolors = __commonJS({
99
99
  });
100
100
 
101
101
  // src/index.ts
102
- import { spawnSync as spawnSync3 } from "child_process";
103
- import { existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync } from "fs";
102
+ import { spawnSync as spawnSync2 } from "child_process";
103
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync3 } from "fs";
104
104
  import { tmpdir } from "os";
105
105
  import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
106
106
  import { fileURLToPath as fileURLToPath2 } from "url";
107
107
  import { Command } from "commander";
108
+ import { parse as parse3, stringify } from "yaml";
108
109
 
109
110
  // ../core/dist/index.js
111
+ var import_picocolors = __toESM(require_picocolors(), 1);
112
+ var import_picocolors2 = __toESM(require_picocolors(), 1);
110
113
  import { readFileSync } from "fs";
111
114
  import { join as join2 } from "path";
112
115
  import { parse } from "yaml";
@@ -115,7 +118,8 @@ import { randomUUID } from "crypto";
115
118
  import { basename, extname } from "path";
116
119
  import { spawnSync } from "child_process";
117
120
  import { basename as basename2 } from "path";
118
- var import_picocolors = __toESM(require_picocolors(), 1);
121
+ import { mkdirSync, writeFileSync } from "fs";
122
+ import { join as join22 } from "path";
119
123
  import { createHash } from "crypto";
120
124
  import { readFileSync as readFileSync2, readdirSync } from "fs";
121
125
  import { join as join3 } from "path";
@@ -141,14 +145,21 @@ import { dirname as dirname2, join as join4 } from "path";
141
145
  import { existsSync } from "fs";
142
146
  import { dirname as dirname22, join as join5 } from "path";
143
147
  import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
144
- import { spawnSync as spawnSync2 } from "child_process";
145
148
  import { join as join6, relative as relative2 } from "path";
146
149
  import { z as z3 } from "zod";
147
150
  var ConfigSchema = z.object({
148
151
  ignore: z.array(z.string()).optional(),
152
+ suppress: z.array(
153
+ z.object({
154
+ rule: z.string().min(1),
155
+ file: z.string().min(1),
156
+ line: z.number().int().positive().optional(),
157
+ reason: z.string().optional()
158
+ })
159
+ ).optional(),
149
160
  rules: z.object({ disable: z.array(z.string()).optional() }).optional()
150
161
  });
151
- var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set() };
162
+ var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set(), suppressions: [] };
152
163
  function loadConfig(root) {
153
164
  let text;
154
165
  try {
@@ -161,15 +172,450 @@ function loadConfig(root) {
161
172
  ignorePrefixes: (parsed.ignore ?? []).map(
162
173
  (p) => p.replace(/\/\*{1,2}$/, "").replace(/\/$/, "")
163
174
  ),
164
- disabledRules: new Set(parsed.rules?.disable ?? [])
175
+ disabledRules: new Set(parsed.rules?.disable ?? []),
176
+ suppressions: parsed.suppress ?? []
165
177
  };
166
178
  }
167
179
  function isIgnored(rel, prefixes) {
168
180
  return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
169
181
  }
182
+ function isSuppressedByConfig(ruleId, rel, line, suppressions) {
183
+ return suppressions.some(
184
+ (s) => s.rule === ruleId && s.file === rel && (s.line === void 0 || s.line === line)
185
+ );
186
+ }
187
+ var SEVERITY_RANK = {
188
+ block: 0,
189
+ high: 1,
190
+ warn: 2,
191
+ info: 3
192
+ };
193
+ var ROUTE_ASSET_RE = /^(?:app|pages)\/(.+?)(?:\/route)?\.[tj]sx?$/;
194
+ function envAsset(finding) {
195
+ const evidence = finding.spans[0]?.evidence ?? "";
196
+ const upper = evidence.match(/[A-Z][A-Z0-9_]{3,}/)?.[0];
197
+ if (upper) return `env:${upper}`;
198
+ if (finding.finding.rule_id.includes("supabase")) return "env:SUPABASE_SERVICE_ROLE_KEY";
199
+ if (finding.finding.rule_id.includes("next-public")) return "env:NEXT_PUBLIC_*SECRET*";
200
+ return "env:server_secret";
201
+ }
202
+ function routeAsset(finding) {
203
+ const normalized = finding.finding.file.split("\\").join("/");
204
+ const route = ROUTE_ASSET_RE.exec(normalized)?.[1];
205
+ if (route) return `/${route.replace(/\/page$|\/route$/, "")}`;
206
+ return normalized;
207
+ }
208
+ function packageAsset(finding) {
209
+ const evidence = finding.spans[0]?.evidence ?? "";
210
+ const quoted = evidence.match(/"([^"]+)"/)?.[1];
211
+ return quoted && quoted.trim().length > 1 ? `package:${quoted}` : "package.json";
212
+ }
213
+ function databaseAsset(finding) {
214
+ if (finding.finding.file.includes("firestore")) return "firestore.rules";
215
+ if (finding.finding.file.includes("convex/")) return `convex:${routeAsset(finding)}`;
216
+ if (finding.finding.file.includes("supabase/")) return "supabase:database";
217
+ return finding.finding.file;
218
+ }
219
+ var RULE_TEMPLATES = {
220
+ "ss/client/supabase-service-role-in-client": {
221
+ actor: "frontend_bundle",
222
+ lane: "env_variable",
223
+ asset: envAsset,
224
+ permission: "read",
225
+ condition: "client_exposed",
226
+ risk: "client_to_server_secret",
227
+ provider: "supabase"
228
+ },
229
+ "ss/secrets/supabase-service-role-key": {
230
+ actor: "server_runtime",
231
+ lane: "env_variable",
232
+ asset: envAsset,
233
+ permission: "read",
234
+ condition: "hardcoded_or_committed",
235
+ risk: "server_secret_exposure",
236
+ provider: "supabase"
237
+ },
238
+ "ss/supabase/rls-disabled": {
239
+ actor: "public_user",
240
+ lane: "database",
241
+ asset: databaseAsset,
242
+ permission: "read",
243
+ condition: "rls_disabled",
244
+ risk: "public_database_access",
245
+ provider: "supabase"
246
+ },
247
+ "ss/supabase/permissive-policy": {
248
+ actor: "public_user",
249
+ lane: "database",
250
+ asset: databaseAsset,
251
+ permission: "write",
252
+ condition: "permissive_policy",
253
+ risk: "public_database_access",
254
+ provider: "supabase"
255
+ },
256
+ "ss/firebase/open-rules": {
257
+ actor: "public_user",
258
+ lane: "database",
259
+ asset: databaseAsset,
260
+ permission: "write",
261
+ condition: "open_rules",
262
+ risk: "public_database_access",
263
+ provider: "firebase"
264
+ },
265
+ "ss/client/firebase-admin-in-client": {
266
+ actor: "frontend_bundle",
267
+ lane: "env_variable",
268
+ asset: () => "firebase:admin_sdk",
269
+ permission: "admin",
270
+ condition: "client_exposed",
271
+ risk: "client_to_admin",
272
+ provider: "firebase"
273
+ },
274
+ "ss/convex/mutation-no-auth": {
275
+ actor: "public_user",
276
+ lane: "server_action",
277
+ asset: databaseAsset,
278
+ permission: "execute",
279
+ condition: "no_auth_check",
280
+ risk: "anonymous_write",
281
+ provider: "convex"
282
+ },
283
+ "ss/convex/internal-not-internal": {
284
+ actor: "public_user",
285
+ lane: "server_action",
286
+ asset: databaseAsset,
287
+ permission: "execute",
288
+ condition: "internal_function_public",
289
+ risk: "untrusted_admin_surface",
290
+ provider: "convex"
291
+ },
292
+ "ss/auth/api-route-no-auth": {
293
+ actor: "public_user",
294
+ lane: "http_route",
295
+ asset: routeAsset,
296
+ permission: "execute",
297
+ condition: "no_auth_or_signature",
298
+ risk: "anonymous_execute",
299
+ provider: "web"
300
+ },
301
+ "ss/auth/admin-route-unprotected": {
302
+ actor: "public_user",
303
+ lane: "http_route",
304
+ asset: routeAsset,
305
+ permission: "admin",
306
+ condition: "no_server_auth",
307
+ risk: "untrusted_admin_surface",
308
+ provider: "web"
309
+ },
310
+ "ss/auth/client-only-guard": {
311
+ actor: "authenticated_user",
312
+ lane: "http_route",
313
+ asset: routeAsset,
314
+ permission: "admin",
315
+ condition: "client_only_role_check",
316
+ risk: "client_to_admin",
317
+ provider: "web"
318
+ },
319
+ "ss/auth/cors-wildcard-with-credentials": {
320
+ actor: "public_user",
321
+ lane: "http_route",
322
+ asset: routeAsset,
323
+ permission: "execute",
324
+ condition: "wildcard_origin_with_credentials",
325
+ risk: "cors_credential_theft",
326
+ provider: "web"
327
+ },
328
+ "ss/server/route-no-auth": {
329
+ actor: "public_user",
330
+ lane: "self_hosted_server",
331
+ asset: routeAsset,
332
+ permission: "execute",
333
+ condition: "no_auth_middleware",
334
+ risk: "anonymous_execute",
335
+ provider: "self-hosted"
336
+ },
337
+ "ss/deploy/public-env-secret": {
338
+ actor: "deploy_platform",
339
+ lane: "deploy_config",
340
+ asset: envAsset,
341
+ permission: "read",
342
+ condition: "public_or_build_exposed",
343
+ risk: "deploy_secret_exposure",
344
+ provider: "deploy"
345
+ },
346
+ "ss/client/server-secret-env-in-client": {
347
+ actor: "frontend_bundle",
348
+ lane: "env_variable",
349
+ asset: envAsset,
350
+ permission: "read",
351
+ condition: "client_exposed",
352
+ risk: "client_to_server_secret",
353
+ provider: "web"
354
+ },
355
+ "ss/client/next-public-secret": {
356
+ actor: "frontend_bundle",
357
+ lane: "env_variable",
358
+ asset: envAsset,
359
+ permission: "read",
360
+ condition: "next_public_exposed",
361
+ risk: "client_to_server_secret",
362
+ provider: "nextjs"
363
+ },
364
+ "ss/secrets/env-file-committed": {
365
+ actor: "ai_agent",
366
+ lane: "filesystem",
367
+ asset: () => ".env",
368
+ permission: "modify",
369
+ condition: "committed_or_staged",
370
+ risk: "agent_to_secret",
371
+ provider: "agent"
372
+ },
373
+ "ss/agent/secrets-in-agent-files": {
374
+ actor: "ai_agent",
375
+ lane: "agent_tooling",
376
+ asset: routeAsset,
377
+ permission: "read",
378
+ condition: "agent_instruction_exposes_secret",
379
+ risk: "agent_to_secret",
380
+ provider: "agent"
381
+ },
382
+ "ss/agent/mcp-inline-credentials": {
383
+ actor: "ai_agent",
384
+ lane: "agent_tooling",
385
+ asset: routeAsset,
386
+ permission: "read",
387
+ condition: "inline_mcp_credentials",
388
+ risk: "agent_to_secret",
389
+ provider: "agent"
390
+ },
391
+ "ss/agent/overbroad-permissions": {
392
+ actor: "ai_agent",
393
+ lane: "agent_tooling",
394
+ asset: routeAsset,
395
+ permission: "execute",
396
+ condition: "overbroad_tool_permission",
397
+ risk: "agent_to_secret",
398
+ provider: "agent"
399
+ },
400
+ "ss/deps/postinstall-script": {
401
+ actor: "dependency",
402
+ lane: "package_install",
403
+ asset: packageAsset,
404
+ permission: "execute",
405
+ condition: "install_lifecycle_script",
406
+ risk: "dependency_to_shell",
407
+ provider: "npm"
408
+ },
409
+ "ss/deps/unpinned-spec": {
410
+ actor: "dependency",
411
+ lane: "package_install",
412
+ asset: packageAsset,
413
+ permission: "install",
414
+ condition: "latest_or_star",
415
+ risk: "unreproducible_dependency",
416
+ provider: "npm"
417
+ },
418
+ "ss/deps/known-vuln": {
419
+ actor: "dependency",
420
+ lane: "package_install",
421
+ asset: packageAsset,
422
+ permission: "execute",
423
+ condition: "known_vulnerability",
424
+ risk: "known_vulnerable_dependency",
425
+ provider: "npm"
426
+ },
427
+ "ss/deps/hallucinated-package": {
428
+ actor: "dependency",
429
+ lane: "package_install",
430
+ asset: packageAsset,
431
+ permission: "install",
432
+ condition: "package_not_found",
433
+ risk: "unknown_package",
434
+ provider: "npm"
435
+ },
436
+ "ss/deps/no-lockfile": {
437
+ actor: "dependency",
438
+ lane: "package_install",
439
+ asset: () => "lockfile",
440
+ permission: "install",
441
+ condition: "missing_lockfile",
442
+ risk: "unreproducible_dependency",
443
+ provider: "npm"
444
+ },
445
+ "ss/secrets/hardcoded-provider-key": {
446
+ actor: "server_runtime",
447
+ lane: "env_variable",
448
+ asset: envAsset,
449
+ permission: "read",
450
+ condition: "hardcoded",
451
+ risk: "server_secret_exposure",
452
+ provider: "provider"
453
+ },
454
+ "ss/secrets/generic-credential-assignment": {
455
+ actor: "server_runtime",
456
+ lane: "env_variable",
457
+ asset: envAsset,
458
+ permission: "read",
459
+ condition: "credential_assignment",
460
+ risk: "server_secret_exposure",
461
+ provider: "provider"
462
+ },
463
+ "ss/secrets/private-key-file": {
464
+ actor: "server_runtime",
465
+ lane: "filesystem",
466
+ asset: routeAsset,
467
+ permission: "read",
468
+ condition: "private_key_file",
469
+ risk: "server_secret_exposure",
470
+ provider: "provider"
471
+ }
472
+ };
473
+ function fallbackTemplate(finding) {
474
+ if (finding.finding.file.includes("coolify") || finding.finding.file.includes("docker")) {
475
+ return {
476
+ actor: "deploy_platform",
477
+ lane: "deploy_config",
478
+ asset: routeAsset,
479
+ permission: "execute",
480
+ condition: "deploy_config_change",
481
+ risk: "deploy_secret_exposure",
482
+ provider: "deploy"
483
+ };
484
+ }
485
+ return {
486
+ actor: "server_runtime",
487
+ lane: "filesystem",
488
+ asset: routeAsset,
489
+ permission: "read",
490
+ condition: "scanner_finding",
491
+ risk: "server_secret_exposure",
492
+ provider: "generic"
493
+ };
494
+ }
495
+ function toLane(finding) {
496
+ const template = RULE_TEMPLATES[finding.finding.rule_id] ?? fallbackTemplate(finding);
497
+ return {
498
+ actor: template.actor,
499
+ lane: template.lane,
500
+ asset: template.asset(finding),
501
+ permission: template.permission,
502
+ condition: template.condition,
503
+ risk: template.risk,
504
+ severity: finding.finding.severity,
505
+ provider: template.provider,
506
+ source: {
507
+ rule_id: finding.finding.rule_id,
508
+ title: finding.finding.title,
509
+ file: finding.finding.file,
510
+ line: finding.finding.line,
511
+ evidence: finding.spans[0]?.evidence
512
+ },
513
+ fix: finding.finding.fix
514
+ };
515
+ }
516
+ function countBy(items, pick) {
517
+ const counts = {};
518
+ for (const item of items) counts[pick(item)] = (counts[pick(item)] ?? 0) + 1;
519
+ return counts;
520
+ }
521
+ function buildAccessMap(result) {
522
+ const lanes = result.findings.map(toLane).sort(
523
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.actor.localeCompare(b.actor) || a.asset.localeCompare(b.asset) || a.source.rule_id.localeCompare(b.source.rule_id)
524
+ );
525
+ return {
526
+ schema: "seamshield.access-map/v1",
527
+ target: result.target,
528
+ policy_bundle_digest: result.policyBundleDigest,
529
+ summary: {
530
+ lanes_total: lanes.length,
531
+ by_actor: countBy(lanes, (lane) => lane.actor),
532
+ by_risk: countBy(lanes, (lane) => lane.risk),
533
+ by_severity: countBy(lanes, (lane) => lane.severity)
534
+ },
535
+ lanes
536
+ };
537
+ }
538
+ function buildShipVerdict(result) {
539
+ const access = buildAccessMap(result);
540
+ const critical = access.lanes.filter((lane) => lane.severity === "block" || lane.severity === "high");
541
+ const warnings = access.lanes.filter((lane) => lane.severity === "warn" || lane.severity === "info");
542
+ return {
543
+ schema: "seamshield.ship/v1",
544
+ target: result.target,
545
+ verdict: critical.length > 0 ? "UNSAFE TO SHIP" : "SAFE TO SHIP",
546
+ exitCode: critical.length > 0 ? 1 : 0,
547
+ access,
548
+ critical,
549
+ warnings
550
+ };
551
+ }
552
+ function renderAccessJson(access) {
553
+ return JSON.stringify(access, null, 2);
554
+ }
555
+ var SEVERITY_COLOR = {
556
+ block: (s) => import_picocolors.default.red(s),
557
+ high: (s) => import_picocolors.default.magenta(s),
558
+ warn: (s) => import_picocolors.default.yellow(s),
559
+ info: (s) => import_picocolors.default.dim(s)
560
+ };
561
+ function renderLane(lane) {
562
+ return [
563
+ ` -> ${lane.lane} -> ${lane.asset}`,
564
+ ` permission: ${lane.permission}`,
565
+ ` condition: ${lane.condition}`,
566
+ ` risk: ${SEVERITY_COLOR[lane.severity](lane.risk)} (${lane.source.rule_id})`
567
+ ];
568
+ }
569
+ function renderAccessTable(access) {
570
+ const lines = [import_picocolors.default.bold("Access Map"), ""];
571
+ if (access.lanes.length === 0) {
572
+ lines.push(import_picocolors.default.green("No dangerous access lanes found."));
573
+ return lines.join("\n");
574
+ }
575
+ const actors = [...new Set(access.lanes.map((lane) => lane.actor))];
576
+ for (const actor of actors) {
577
+ lines.push(import_picocolors.default.bold(actor));
578
+ for (const lane of access.lanes.filter((candidate) => candidate.actor === actor)) {
579
+ lines.push(...renderLane(lane));
580
+ }
581
+ lines.push("");
582
+ }
583
+ lines.push(import_picocolors.default.dim(`policy bundle ${access.policy_bundle_digest.slice(0, 12)}`));
584
+ return lines.join("\n");
585
+ }
586
+ function renderShipTable(verdict) {
587
+ const lines = [import_picocolors.default.bold("SeamShield Ship Check"), "", `Verdict: ${verdict.verdict}`, ""];
588
+ if (verdict.critical.length > 0) {
589
+ lines.push(import_picocolors.default.red("Critical"));
590
+ verdict.critical.forEach((lane, i) => {
591
+ lines.push(
592
+ `${i + 1}. ${lane.actor} can ${lane.permission} ${lane.asset} (${lane.risk})`,
593
+ ` ${lane.source.file}:${lane.source.line} ${lane.source.rule_id}`
594
+ );
595
+ });
596
+ lines.push("");
597
+ }
598
+ if (verdict.warnings.length > 0) {
599
+ lines.push(import_picocolors.default.yellow("Warnings"));
600
+ verdict.warnings.forEach((lane, i) => {
601
+ lines.push(
602
+ `${i + 1}. ${lane.actor} can ${lane.permission} ${lane.asset} (${lane.risk})`,
603
+ ` ${lane.source.file}:${lane.source.line} ${lane.source.rule_id}`
604
+ );
605
+ });
606
+ lines.push("");
607
+ }
608
+ if (verdict.critical.length === 0 && verdict.warnings.length === 0) {
609
+ lines.push(import_picocolors.default.green("No dangerous access lanes found."), "");
610
+ }
611
+ lines.push("Next:", "npx seamshield access", "npx seamshield fix-plan");
612
+ return lines.join("\n");
613
+ }
170
614
  function matchBasenamePattern(name, pattern) {
171
- if (pattern.startsWith("*")) return name.endsWith(pattern.slice(1));
172
- if (pattern.endsWith("*")) return name.startsWith(pattern.slice(0, -1));
615
+ if (pattern.includes("*")) {
616
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
617
+ return new RegExp(`^${escaped}$`).test(name);
618
+ }
173
619
  return name === pattern;
174
620
  }
175
621
  function fileMatchesRule(file, check) {
@@ -304,7 +750,38 @@ function promptFor(finding) {
304
750
  `Fix: ${finding.finding.fix.agent_prompt}`
305
751
  ].join("\n");
306
752
  }
307
- function buildFixPlan(result) {
753
+ function rulesForAgent(agent) {
754
+ const base = [
755
+ "Do not print, rename, commit, or expose secret values.",
756
+ "Do not move server credentials into public/client env names.",
757
+ "Do not weaken authentication, authorization, database rules, storage rules, or CORS.",
758
+ "Preserve current user-facing behavior unless the risky access lane requires a safer boundary.",
759
+ "Re-run `npx seamshield ship --offline` after changes."
760
+ ];
761
+ if (agent === "codex") return [...base, "Use focused edits and run the repo's typecheck/tests before claiming done."];
762
+ if (agent === "claude") return [...base, "Respect existing CLAUDE.md project instructions and tool hooks."];
763
+ if (agent === "cursor") return [...base, "Respect existing Cursor rules and keep generated code inside the intended files."];
764
+ return base;
765
+ }
766
+ function lanePrompt(lane) {
767
+ return [
768
+ `## Issue`,
769
+ `${lane.actor} can ${lane.permission} ${lane.asset} through ${lane.lane}.`,
770
+ "",
771
+ `Risk: ${lane.risk}`,
772
+ `Condition: ${lane.condition}`,
773
+ `Source: ${lane.source.file}:${lane.source.line} (${lane.source.rule_id})`,
774
+ "",
775
+ `## Goal`,
776
+ lane.fix.summary,
777
+ "",
778
+ `## Steps`,
779
+ lane.fix.agent_prompt
780
+ ].join("\n");
781
+ }
782
+ function buildFixPlan(result, options = {}) {
783
+ const agent = options.agent ?? "generic";
784
+ const access = buildAccessMap(result);
308
785
  const items = result.findings.map((finding) => ({
309
786
  rule_id: finding.finding.rule_id,
310
787
  severity: finding.finding.severity,
@@ -318,19 +795,31 @@ function buildFixPlan(result) {
318
795
  return {
319
796
  schema: "seamshield.fix-plan/v1",
320
797
  target: result.target,
798
+ agent,
321
799
  policy_bundle_digest: result.policyBundleDigest,
322
- summary: { findings_total: result.findings.length },
800
+ summary: { findings_total: result.findings.length, access_lanes_total: access.lanes.length },
323
801
  items,
802
+ access_lanes: access.lanes,
324
803
  agent_markdown: [
325
804
  "# SeamShield Fix Plan",
326
805
  "",
327
- "Apply these fixes without exposing or logging secret values.",
806
+ ...rulesForAgent(agent).map((rule) => `- ${rule}`),
328
807
  "",
329
- ...items.map((item) => item.agent_prompt),
808
+ ...access.lanes.map(lanePrompt),
330
809
  ""
331
810
  ].join("\n")
332
811
  };
333
812
  }
813
+ function dateStamp(now = /* @__PURE__ */ new Date()) {
814
+ return now.toISOString().slice(0, 10);
815
+ }
816
+ function writeMarkdownFixPlan(result, options = {}) {
817
+ const dir = join22(result.target, ".seamshield", "fix-plans");
818
+ mkdirSync(dir, { recursive: true });
819
+ const path = join22(dir, `${dateStamp(options.now)}-critical-access-risks.md`);
820
+ writeFileSync(path, buildFixPlan(result, { agent: options.agent }).agent_markdown);
821
+ return path;
822
+ }
334
823
  var PatternSchema = z2.object({
335
824
  name: z2.string().min(1),
336
825
  regex: z2.string().min(1)
@@ -474,42 +963,42 @@ function renderSarif(result) {
474
963
  2
475
964
  );
476
965
  }
477
- var SEVERITY_COLOR = {
478
- block: (s) => import_picocolors.default.red(s),
479
- high: (s) => import_picocolors.default.magenta(s),
480
- warn: (s) => import_picocolors.default.yellow(s),
481
- info: (s) => import_picocolors.default.dim(s)
966
+ var SEVERITY_COLOR2 = {
967
+ block: (s) => import_picocolors2.default.red(s),
968
+ high: (s) => import_picocolors2.default.magenta(s),
969
+ warn: (s) => import_picocolors2.default.yellow(s),
970
+ info: (s) => import_picocolors2.default.dim(s)
482
971
  };
483
972
  function renderTable(result) {
484
973
  const lines = [];
485
974
  lines.push(
486
- `${import_picocolors.default.bold(`SeamShield v${result.engineVersion}`)}${import_picocolors.default.dim(
975
+ `${import_picocolors2.default.bold(`SeamShield v${result.engineVersion}`)}${import_picocolors2.default.dim(
487
976
  ` \u2014 ${result.filesScanned} files, ${result.rulesLoaded} rules`
488
977
  )}`
489
978
  );
490
979
  lines.push("");
491
980
  if (result.findings.length === 0) {
492
- lines.push(import_picocolors.default.green("\u2713 No findings."));
981
+ lines.push(import_picocolors2.default.green("\u2713 No findings."));
493
982
  } else {
494
983
  for (const f of result.findings) {
495
- const sev = SEVERITY_COLOR[f.finding.severity](
984
+ const sev = SEVERITY_COLOR2[f.finding.severity](
496
985
  f.finding.severity.toUpperCase().padEnd(5)
497
986
  );
498
- lines.push(`${sev} ${f.finding.rule_id} ${import_picocolors.default.bold(`${f.finding.file}:${f.finding.line}`)}`);
987
+ lines.push(`${sev} ${f.finding.rule_id} ${import_picocolors2.default.bold(`${f.finding.file}:${f.finding.line}`)}`);
499
988
  lines.push(` ${f.finding.title}`);
500
989
  const evidence = f.spans[0]?.evidence;
501
- if (evidence) lines.push(import_picocolors.default.dim(` evidence: ${evidence}`));
502
- lines.push(` ${import_picocolors.default.cyan("fix:")} ${f.finding.fix.summary}`);
990
+ if (evidence) lines.push(import_picocolors2.default.dim(` evidence: ${evidence}`));
991
+ lines.push(` ${import_picocolors2.default.cyan("fix:")} ${f.finding.fix.summary}`);
503
992
  lines.push("");
504
993
  }
505
994
  const counts = /* @__PURE__ */ new Map();
506
995
  for (const f of result.findings) {
507
996
  counts.set(f.finding.severity, (counts.get(f.finding.severity) ?? 0) + 1);
508
997
  }
509
- const parts = [...counts.entries()].map(([sev, n]) => SEVERITY_COLOR[sev](`${n} ${sev}`));
510
- lines.push(`${import_picocolors.default.bold(`${result.findings.length} findings`)} (${parts.join(", ")})`);
998
+ const parts = [...counts.entries()].map(([sev, n]) => SEVERITY_COLOR2[sev](`${n} ${sev}`));
999
+ lines.push(`${import_picocolors2.default.bold(`${result.findings.length} findings`)} (${parts.join(", ")})`);
511
1000
  }
512
- lines.push(import_picocolors.default.dim(`policy bundle ${result.policyBundleDigest.slice(0, 12)}`));
1001
+ lines.push(import_picocolors2.default.dim(`policy bundle ${result.policyBundleDigest.slice(0, 12)}`));
513
1002
  return lines.join("\n");
514
1003
  }
515
1004
  var DEP_FIELDS = [
@@ -693,12 +1182,6 @@ function checkNoLockfile(rule, ctx) {
693
1182
  }
694
1183
  return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
695
1184
  }
696
- var SEVERITY_RANK = {
697
- block: 0,
698
- high: 1,
699
- warn: 2,
700
- info: 3
701
- };
702
1185
  var SKIP_DIRS = /* @__PURE__ */ new Set([
703
1186
  "node_modules",
704
1187
  ".git",
@@ -707,6 +1190,8 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
707
1190
  "build",
708
1191
  "out",
709
1192
  "coverage",
1193
+ "target",
1194
+ ".gradle",
710
1195
  ".turbo",
711
1196
  ".vercel",
712
1197
  ".cache",
@@ -714,8 +1199,6 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
714
1199
  ]);
715
1200
  var MAX_FILE_BYTES = 1e6;
716
1201
  function walk(root) {
717
- const gitFiles = gitTrackedAndUnignoredFiles(root);
718
- if (gitFiles) return gitFiles;
719
1202
  const files = [];
720
1203
  const visit = (dir) => {
721
1204
  let entries;
@@ -746,15 +1229,6 @@ function walk(root) {
746
1229
  visit(root);
747
1230
  return files;
748
1231
  }
749
- function gitTrackedAndUnignoredFiles(root) {
750
- const result = spawnSync2(
751
- "git",
752
- ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard"],
753
- { encoding: "utf8", timeout: 1500 }
754
- );
755
- if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
756
- return result.stdout.split("\n").filter(Boolean).filter((rel) => !rel.split("/").some((part) => SKIP_DIRS.has(part))).filter((rel) => rel !== ".claude/worktrees" && !rel.includes("/.claude/worktrees/")).filter((rel) => rel !== ".worktrees" && !rel.startsWith(".worktrees/")).map((rel) => ({ abs: join6(root, rel), rel }));
757
- }
758
1232
  var FileCache = class {
759
1233
  cache = /* @__PURE__ */ new Map();
760
1234
  read(abs) {
@@ -798,7 +1272,14 @@ function scan(target, options = {}) {
798
1272
  throw new Error(`${rule.id}: unknown builtin check "${rule.check.builtin}"`);
799
1273
  }
800
1274
  }
801
- findings = findings.filter((f) => !isIgnored(f.finding.file, config.ignorePrefixes));
1275
+ findings = findings.filter((f) => !isIgnored(f.finding.file, config.ignorePrefixes)).filter(
1276
+ (f) => !isSuppressedByConfig(
1277
+ f.finding.rule_id,
1278
+ f.finding.file,
1279
+ f.finding.line,
1280
+ config.suppressions
1281
+ )
1282
+ ).map((f) => refineFinding(f, cache, files));
802
1283
  findings.sort(
803
1284
  (a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
804
1285
  );
@@ -846,7 +1327,12 @@ async function scanAsync(target, options = {}) {
846
1327
  }
847
1328
  }
848
1329
  result.findings = [...result.findings, ...dependencyFindings].filter(
849
- (f) => !isIgnored(f.finding.file, config.ignorePrefixes)
1330
+ (f) => !isIgnored(f.finding.file, config.ignorePrefixes) && !isSuppressedByConfig(
1331
+ f.finding.rule_id,
1332
+ f.finding.file,
1333
+ f.finding.line,
1334
+ config.suppressions
1335
+ )
850
1336
  );
851
1337
  result.findings.sort(
852
1338
  (a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
@@ -855,6 +1341,22 @@ async function scanAsync(target, options = {}) {
855
1341
  result.networkSkipped = false;
856
1342
  return result;
857
1343
  }
1344
+ function refineFinding(finding, cache, files) {
1345
+ if (finding.finding.rule_id !== "ss/auth/client-only-guard") return finding;
1346
+ const file = files.find((candidate) => candidate.rel === finding.finding.file);
1347
+ if (!file) return finding;
1348
+ const content = cache.read(file.abs);
1349
+ if (!content) return finding;
1350
+ const hasServerDataBoundary = /\b(?:fetch|axios|useQuery|useMutation|useAction|api\.|convex|server action|route\.ts|\/api\/)\b/i.test(
1351
+ content
1352
+ );
1353
+ if (hasServerDataBoundary) return finding;
1354
+ return {
1355
+ ...finding,
1356
+ finding: { ...finding.finding, severity: "info" },
1357
+ decision: "scan"
1358
+ };
1359
+ }
858
1360
  function computeExitCode(findings, failOn) {
859
1361
  if (failOn === "never") return 0;
860
1362
  const threshold = SEVERITY_RANK[failOn];
@@ -908,13 +1410,21 @@ var pkg = JSON.parse(
908
1410
  readFileSync6(new URL("../package.json", import.meta.url), "utf8")
909
1411
  );
910
1412
  var FORMATS = ["table", "json", "sarif"];
1413
+ var ACCESS_FORMATS = ["table", "json"];
911
1414
  var FAIL_ON = ["block", "high", "warn", "never"];
912
- function assertOptions(opts) {
913
- if (opts.format && !FORMATS.includes(opts.format)) {
914
- console.error(`seamshield: unknown --format "${opts.format}" (expected: ${FORMATS.join(", ")})`);
1415
+ var FIX_AGENTS = ["claude", "cursor", "codex", "generic"];
1416
+ function assertChoice(value, allowed, label) {
1417
+ if (value && !allowed.includes(value)) {
1418
+ console.error(`seamshield: unknown --${label} "${value}" (expected: ${allowed.join(", ")})`);
915
1419
  process.exitCode = 2;
916
1420
  return false;
917
1421
  }
1422
+ return true;
1423
+ }
1424
+ function assertOptions(opts) {
1425
+ if (!assertChoice(opts.format, FORMATS, "format")) {
1426
+ return false;
1427
+ }
918
1428
  if (opts.failOn && !FAIL_ON.includes(opts.failOn)) {
919
1429
  console.error(`seamshield: unknown --fail-on "${opts.failOn}" (expected: ${FAIL_ON.join(", ")})`);
920
1430
  process.exitCode = 2;
@@ -946,6 +1456,20 @@ async function runScan(path, opts) {
946
1456
  process.exitCode = 2;
947
1457
  }
948
1458
  }
1459
+ async function readScanForCommand(path, offline = true) {
1460
+ if (!existsSync2(path)) {
1461
+ console.error(`seamshield: path not found: ${path}`);
1462
+ process.exitCode = 2;
1463
+ return null;
1464
+ }
1465
+ try {
1466
+ return await scanAsync(path, { failOn: "never", network: offline ? "off" : "on" });
1467
+ } catch (error) {
1468
+ console.error(`seamshield: scan failed: ${error instanceof Error ? error.message : String(error)}`);
1469
+ process.exitCode = 2;
1470
+ return null;
1471
+ }
1472
+ }
949
1473
  function writeAgentContext(target, kind) {
950
1474
  const body = [
951
1475
  "# SEAMSHIELD",
@@ -960,24 +1484,65 @@ function writeAgentContext(target, kind) {
960
1484
  ].join("\n");
961
1485
  if (kind === "cursor") {
962
1486
  const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
963
- mkdirSync(dirname3(out2), { recursive: true });
964
- writeFileSync(out2, body);
1487
+ mkdirSync3(dirname3(out2), { recursive: true });
1488
+ writeFileSync3(out2, body);
965
1489
  return out2;
966
1490
  }
967
1491
  const out = join7(target, "CLAUDE.md");
968
1492
  const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
969
1493
  const marker = "# SEAMSHIELD";
970
1494
  const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
971
- writeFileSync(out, next.endsWith("\n") ? next : `${next}
1495
+ writeFileSync3(out, next.endsWith("\n") ? next : `${next}
972
1496
  `);
973
1497
  return out;
974
1498
  }
1499
+ function readTriageConfig(target) {
1500
+ const path = join7(target, ".seamshield", "config.yaml");
1501
+ if (!existsSync2(path)) return {};
1502
+ const parsed = parse3(readFileSync6(path, "utf8"));
1503
+ return parsed && typeof parsed === "object" ? parsed : {};
1504
+ }
1505
+ function writeTriageConfig(target, config) {
1506
+ const out = join7(target, ".seamshield", "config.yaml");
1507
+ mkdirSync3(dirname3(out), { recursive: true });
1508
+ writeFileSync3(out, stringify(config));
1509
+ return out;
1510
+ }
1511
+ async function writeTriageSuppressions(path, opts) {
1512
+ const target = resolve2(path);
1513
+ const result = await readScanForCommand(target, !opts.online);
1514
+ if (!result) return;
1515
+ const config = readTriageConfig(target);
1516
+ const suppress = config.suppress ?? [];
1517
+ const existing = new Set(suppress.map((s) => `${s.rule}\0${s.file}\0${s.line ?? ""}`));
1518
+ const candidates = result.findings.filter((finding) => {
1519
+ if (opts.rule && finding.finding.rule_id !== opts.rule) return false;
1520
+ if (!opts.includeBlock && finding.finding.severity === "block") return false;
1521
+ return true;
1522
+ });
1523
+ for (const finding of candidates) {
1524
+ const entry = {
1525
+ rule: finding.finding.rule_id,
1526
+ file: finding.finding.file,
1527
+ line: finding.finding.line,
1528
+ reason: opts.reason ?? "triaged false positive"
1529
+ };
1530
+ const key = `${entry.rule}\0${entry.file}\0${entry.line}`;
1531
+ if (existing.has(key)) continue;
1532
+ suppress.push(entry);
1533
+ existing.add(key);
1534
+ }
1535
+ config.suppress = suppress;
1536
+ const out = writeTriageConfig(target, config);
1537
+ console.log(out);
1538
+ console.log(`Suppressed ${candidates.length} current finding(s).`);
1539
+ }
975
1540
  function currentBin() {
976
1541
  return fileURLToPath2(import.meta.url);
977
1542
  }
978
1543
  function installGuard(target) {
979
1544
  const settingsPath = join7(target, ".claude", "settings.json");
980
- mkdirSync(dirname3(settingsPath), { recursive: true });
1545
+ mkdirSync3(dirname3(settingsPath), { recursive: true });
981
1546
  const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
982
1547
  const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
983
1548
  const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
@@ -988,7 +1553,7 @@ function installGuard(target) {
988
1553
  }
989
1554
  ];
990
1555
  settings.hooks = hooks;
991
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1556
+ writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
992
1557
  `);
993
1558
  return settingsPath;
994
1559
  }
@@ -1025,7 +1590,7 @@ function bashDecision(command) {
1025
1590
  const name = command.match(/npm\s+(?:i|install|add)\s+(@?[\w.-]+\/?[\w.-]*)/)?.[1];
1026
1591
  if (name) {
1027
1592
  const encoded = name.startsWith("@") ? name.replace("/", "%2F") : name;
1028
- const res = spawnSync3("curl", ["-fsSI", `https://registry.npmjs.org/${encoded}`], {
1593
+ const res = spawnSync2("curl", ["-fsSI", `https://registry.npmjs.org/${encoded}`], {
1029
1594
  encoding: "utf8",
1030
1595
  timeout: 750
1031
1596
  });
@@ -1051,8 +1616,8 @@ function guardCheck() {
1051
1616
  }
1052
1617
  const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
1053
1618
  const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
1054
- mkdirSync(dirname3(abs), { recursive: true });
1055
- writeFileSync(abs, proposed.content);
1619
+ mkdirSync3(dirname3(abs), { recursive: true });
1620
+ writeFileSync3(abs, proposed.content);
1056
1621
  const result = scan(tempRoot, { network: "off" });
1057
1622
  rmSync(tempRoot, { recursive: true, force: true });
1058
1623
  const block = result.findings.find((f) => f.finding.severity === "block");
@@ -1068,8 +1633,8 @@ function guardCheck() {
1068
1633
  } catch (error) {
1069
1634
  const logPath = join7(process.cwd(), ".seamshield", "guard.log");
1070
1635
  try {
1071
- mkdirSync(dirname3(logPath), { recursive: true });
1072
- writeFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1636
+ mkdirSync3(dirname3(logPath), { recursive: true });
1637
+ writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1073
1638
  `, { flag: "a" });
1074
1639
  } catch {
1075
1640
  }
@@ -1081,7 +1646,23 @@ program.name("seamshield").description("Security scanner for AI-generated apps:
1081
1646
  program.command("scan").description("Scan a project directory and report findings").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json | sarif", "table").option("--fail-on <severity>", "exit 1 at or above: block | high | warn | never", "block").option("--offline", "skip npm registry and OSV network checks").action((path, opts) => {
1082
1647
  return runScan(path, opts);
1083
1648
  });
1084
- program.command("fix-plan").description("Write .seamshield/fix-plan.json with agent-ready fix prompts").argument("[path]", "directory to scan", ".").option("--offline", "skip npm registry and OSV network checks").action(async (path, opts) => {
1649
+ program.command("ship").description("Give a deploy verdict from dangerous access lanes").argument("[path]", "directory to scan", ".").option("--online", "include network-backed dependency intelligence").action(async (path, opts) => {
1650
+ const result = await readScanForCommand(path, !opts.online);
1651
+ if (!result) return;
1652
+ const verdict = buildShipVerdict(result);
1653
+ console.log(renderShipTable(verdict));
1654
+ process.exitCode = verdict.exitCode;
1655
+ });
1656
+ program.command("access").description("Show the normalized Actor -> Lane -> Asset -> Permission -> Condition -> Risk access map").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json", "table").option("--online", "include network-backed dependency intelligence").action(async (path, opts) => {
1657
+ if (!assertChoice(opts.format, ACCESS_FORMATS, "format")) return;
1658
+ const result = await readScanForCommand(path, !opts.online);
1659
+ if (!result) return;
1660
+ const access = buildAccessMap(result);
1661
+ console.log(opts.format === "json" ? renderAccessJson(access) : renderAccessTable(access));
1662
+ process.exitCode = 0;
1663
+ });
1664
+ program.command("fix-plan").description("Write agent-ready fix prompts for dangerous access lanes").argument("[path]", "directory to scan", ".").option("--offline", "skip npm registry and OSV network checks").option("--agent <agent>", "target agent: claude | cursor | codex | generic", "generic").action(async (path, opts) => {
1665
+ if (!assertChoice(opts.agent, FIX_AGENTS, "agent")) return;
1085
1666
  if (!existsSync2(path)) {
1086
1667
  console.error(`seamshield: path not found: ${path}`);
1087
1668
  process.exitCode = 2;
@@ -1089,12 +1670,24 @@ program.command("fix-plan").description("Write .seamshield/fix-plan.json with ag
1089
1670
  }
1090
1671
  const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
1091
1672
  const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
1092
- mkdirSync(dirname3(out), { recursive: true });
1093
- writeFileSync(out, `${JSON.stringify(buildFixPlan(result), null, 2)}
1673
+ mkdirSync3(dirname3(out), { recursive: true });
1674
+ writeFileSync3(out, `${JSON.stringify(buildFixPlan(result, { agent: opts.agent }), null, 2)}
1094
1675
  `);
1676
+ const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
1095
1677
  console.log(out);
1678
+ console.log(markdownOut);
1096
1679
  process.exitCode = result.exitCode;
1097
1680
  });
1681
+ program.command("learn").description("Update local controls from vulnerability intelligence without uploading source").option("--source <path-or-url>", "future rule/control bundle source").action((opts) => {
1682
+ console.log("SeamShield Learn");
1683
+ console.log("Status: local rule/control updates are not wired yet.");
1684
+ console.log("Privacy: no source code was read or uploaded.");
1685
+ if (opts.source) console.log(`Requested source: ${opts.source}`);
1686
+ process.exitCode = 0;
1687
+ });
1688
+ program.command("triage").description("Persist current false-positive decisions into .seamshield/config.yaml").argument("[path]", "directory to scan", ".").option("--rule <rule-id>", "only suppress current findings from one rule").option("--reason <text>", "suppression reason", "triaged false positive").option("--include-block", "also suppress block findings", false).option("--online", "include network-backed dependency intelligence").action(
1689
+ (path, opts) => writeTriageSuppressions(path, opts)
1690
+ );
1098
1691
  program.command("agent-context").description("Write SeamShield agent instructions into CLAUDE.md or Cursor rules").argument("[path]", "project directory", ".").option("--claude", "write CLAUDE.md", false).option("--cursor", "write .cursor/rules/seamshield.mdc", false).action((path, opts) => {
1099
1692
  const target = resolve2(path);
1100
1693
  const kind = opts.cursor ? "cursor" : "claude";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seamshield",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,16 +24,18 @@
24
24
  "nextjs",
25
25
  "supabase",
26
26
  "firebase",
27
+ "convex",
28
+ "vercel",
29
+ "coolify",
27
30
  "claude-code",
28
31
  "cursor",
29
32
  "osv",
30
33
  "sarif"
31
34
  ],
32
35
  "bin": {
33
- "seamshield": "dist/index.js"
36
+ "seamshield": "./dist/index.js"
34
37
  },
35
38
  "files": [
36
- "README.md",
37
39
  "dist",
38
40
  "rules",
39
41
  "schemas"
@@ -41,22 +43,21 @@
41
43
  "engines": {
42
44
  "node": ">=20"
43
45
  },
44
- "scripts": {
45
- "build": "tsup src/index.ts --format esm --clean && tsc -p tsconfig.build.json && rm -rf rules schemas && cp -R ../rules/rules ../rules/schemas .",
46
- "prepack": "pnpm run build",
47
- "test": "vitest run"
48
- },
49
46
  "dependencies": {
50
47
  "commander": "^14.0.0",
51
48
  "yaml": "^2.5.0",
52
49
  "zod": "^4.0.0"
53
50
  },
54
51
  "devDependencies": {
55
- "@seamshield/core": "workspace:*",
56
- "@seamshield/rules": "workspace:*",
57
52
  "@types/node": "^22.0.0",
58
53
  "tsup": "^8.3.0",
59
54
  "typescript": "^5.7.0",
60
- "vitest": "^3.0.0"
55
+ "vitest": "^3.0.0",
56
+ "@seamshield/core": "0.1.0",
57
+ "@seamshield/rules": "0.1.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsup src/index.ts --format esm --clean && tsc -p tsconfig.build.json && rm -rf rules schemas && cp -R ../rules/rules ../rules/schemas .",
61
+ "test": "vitest run"
61
62
  }
62
- }
63
+ }
@@ -11,9 +11,11 @@ check:
11
11
  include:
12
12
  path_contains: ["/api/"]
13
13
  extensions: [".ts", ".js", ".tsx", ".jsx"]
14
+ exclude:
15
+ basenames: ["_*.ts", "_*.js", "_*.tsx", "_*.jsx"]
14
16
  patterns:
15
17
  - name: auth-or-signature-markers
16
- regex: "auth|Auth|session|Session|currentUser|getUser|clerk|Clerk|jwt|JWT|cookie|Cookie|bearer|Bearer|signature|webhook|svix|x-api-key|apiKey|API_KEY|verif|rate[Ll]imit|public"
18
+ regex: "auth|Auth|session|Session|currentUser|getUser|clerk|Clerk|jwt|JWT|cookie|Cookie|bearer|Bearer|signature|webhook|svix|x-api-key|apiKey|API_KEY|verif|rate[Ll]imit|public|410|Gone"
17
19
  fix:
18
20
  summary: Authenticate the caller, or mark the route as intentionally public.
19
21
  agent_prompt: >
@@ -20,14 +20,11 @@ check:
20
20
  basenames:
21
21
  - ".env*"
22
22
  exclude:
23
- dirs:
24
- - "scripts"
25
- - "test"
26
- - "tests"
27
- - "__tests__"
23
+ basenames:
24
+ - "seamshield-release-gate.mjs"
28
25
  patterns:
29
26
  - name: next-public-secret-name
30
- regex: "(?:^\\s*|process\\.env\\.|import\\.meta\\.env\\.)(NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|ADMIN)[A-Z0-9_]*)"
27
+ regex: "NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|ADMIN)[A-Z0-9_]*"
31
28
  fix:
32
29
  summary: Rename the variable to drop the NEXT_PUBLIC_ prefix and read it only on the server.
33
30
  agent_prompt: >
@@ -16,7 +16,7 @@ check:
16
16
  file_contains: "(?<![A-Za-z])mutation\\s*\\("
17
17
  patterns:
18
18
  - name: convex-auth-markers
19
- regex: "ctx\\.auth|getAuthUserId|getUserIdentity|requireAuth|internalMutation"
19
+ regex: "ctx\\.auth|getAuthUserId|getUserIdentity|requireAuth|requireInternalToken|getCaller|requireRole|requireAdmin|internalMutation|rateLimit|rateLimiter|RATE_LIMITED|emailHash|hashKey|normalizeEmail|isValidEmail|waitlist_join"
20
20
  fix:
21
21
  summary: Verify the caller with ctx.auth at the top of the mutation, or make it internal.
22
22
  agent_prompt: >
@@ -0,0 +1,29 @@
1
+ id: ss/deploy/public-env-secret
2
+ severity: high
3
+ title: Deploy config exposes a secret as a public env var
4
+ description: >
5
+ A deploy or self-hosting config exposes a secret-looking value through a
6
+ public/client env namespace. Public env values can be embedded into builds,
7
+ logs, previews, or frontend bundles.
8
+ framework_ref: AI_AGENT_DROP_IN.md#default-security-stance
9
+ check:
10
+ type: regex
11
+ include:
12
+ basenames:
13
+ - "coolify.yaml"
14
+ - "coolify.yml"
15
+ - "docker-compose.yml"
16
+ - "docker-compose.yaml"
17
+ - "vercel.json"
18
+ - ".env"
19
+ - ".env.production"
20
+ patterns:
21
+ - name: public-secret-env
22
+ regex: "(?:NEXT_PUBLIC_|VITE_|PUBLIC_)[A-Z0-9_]*(?:SECRET|PRIVATE|PASSWORD|SERVICE_ROLE|API_KEY|TOKEN)[A-Z0-9_]*"
23
+ fix:
24
+ summary: Move the secret to a server-only environment variable.
25
+ agent_prompt: >
26
+ Remove this value from the public/client env namespace. Put the
27
+ credential in a server-only environment variable, update code to read it
28
+ only from server-side handlers or workers, and rotate the exposed value
29
+ if it may have reached a build artifact or log.
@@ -0,0 +1,22 @@
1
+ id: ss/deps/postinstall-script
2
+ severity: warn
3
+ title: Dependency install lifecycle script can execute shell commands
4
+ description: >
5
+ npm lifecycle scripts such as postinstall run during dependency
6
+ installation. In AI-built apps this creates a supply-chain lane from a
7
+ dependency or pasted package.json change into local shell execution.
8
+ framework_ref: AI_AGENT_DROP_IN.md#supply-chain-stance
9
+ check:
10
+ type: regex
11
+ include:
12
+ basenames: ["package.json"]
13
+ patterns:
14
+ - name: install-lifecycle-script
15
+ regex: "\"(?:preinstall|install|postinstall)\"\\s*:"
16
+ fix:
17
+ summary: Remove install lifecycle scripts unless they are reviewed and required.
18
+ agent_prompt: >
19
+ Review why this install lifecycle script is needed. If it is not
20
+ required, remove it. If it is required, pin the dependency or script
21
+ owner, document the command, and make sure it cannot download or execute
22
+ unreviewed remote code.
@@ -0,0 +1,24 @@
1
+ id: ss/server/route-no-auth
2
+ severity: warn
3
+ title: Self-hosted server route with no recognizable auth middleware
4
+ description: >
5
+ An Express, Fastify, or Hono route handler contains no recognizable auth,
6
+ signature, token, or public-route marker. Review whether this self-hosted
7
+ server endpoint should be callable by anyone.
8
+ framework_ref: AI_AGENT_DROP_IN.md#default-security-stance
9
+ check:
10
+ type: absence
11
+ include:
12
+ extensions: [".ts", ".js", ".tsx", ".jsx", ".mjs"]
13
+ path_contains: ["server", "api", "routes"]
14
+ file_contains: "\\b(?:app|router|server)\\.(?:get|post|put|patch|delete|route)\\s*\\("
15
+ patterns:
16
+ - name: server-auth-markers
17
+ regex: "auth|Auth|session|Session|jwt|JWT|bearer|Bearer|cookie|Cookie|signature|webhook|x-api-key|apiKey|API_KEY|requireAuth|verify|public|health"
18
+ fix:
19
+ summary: Add auth middleware or mark the route as intentionally public.
20
+ agent_prompt: >
21
+ Decide whether this server route should be public. If not, add auth,
22
+ API-key, session, or webhook-signature verification before the handler
23
+ does work. If it is intentionally public, add a short public-route
24
+ comment and a seamshield-ignore for this rule.