seamshield 0.1.2 → 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
@@ -100,13 +100,16 @@ var require_picocolors = __commonJS({
100
100
 
101
101
  // src/index.ts
102
102
  import { spawnSync as spawnSync2 } from "child_process";
103
- import { existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync } from "fs";
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";
@@ -145,9 +149,17 @@ import { join as join6, relative as relative2 } from "path";
145
149
  import { z as z3 } from "zod";
146
150
  var ConfigSchema = z.object({
147
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(),
148
160
  rules: z.object({ disable: z.array(z.string()).optional() }).optional()
149
161
  });
150
- var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set() };
162
+ var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set(), suppressions: [] };
151
163
  function loadConfig(root) {
152
164
  let text;
153
165
  try {
@@ -160,15 +172,450 @@ function loadConfig(root) {
160
172
  ignorePrefixes: (parsed.ignore ?? []).map(
161
173
  (p) => p.replace(/\/\*{1,2}$/, "").replace(/\/$/, "")
162
174
  ),
163
- disabledRules: new Set(parsed.rules?.disable ?? [])
175
+ disabledRules: new Set(parsed.rules?.disable ?? []),
176
+ suppressions: parsed.suppress ?? []
164
177
  };
165
178
  }
166
179
  function isIgnored(rel, prefixes) {
167
180
  return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
168
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
+ }
169
614
  function matchBasenamePattern(name, pattern) {
170
- if (pattern.startsWith("*")) return name.endsWith(pattern.slice(1));
171
- 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
+ }
172
619
  return name === pattern;
173
620
  }
174
621
  function fileMatchesRule(file, check) {
@@ -303,7 +750,38 @@ function promptFor(finding) {
303
750
  `Fix: ${finding.finding.fix.agent_prompt}`
304
751
  ].join("\n");
305
752
  }
306
- 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);
307
785
  const items = result.findings.map((finding) => ({
308
786
  rule_id: finding.finding.rule_id,
309
787
  severity: finding.finding.severity,
@@ -317,19 +795,31 @@ function buildFixPlan(result) {
317
795
  return {
318
796
  schema: "seamshield.fix-plan/v1",
319
797
  target: result.target,
798
+ agent,
320
799
  policy_bundle_digest: result.policyBundleDigest,
321
- summary: { findings_total: result.findings.length },
800
+ summary: { findings_total: result.findings.length, access_lanes_total: access.lanes.length },
322
801
  items,
802
+ access_lanes: access.lanes,
323
803
  agent_markdown: [
324
804
  "# SeamShield Fix Plan",
325
805
  "",
326
- "Apply these fixes without exposing or logging secret values.",
806
+ ...rulesForAgent(agent).map((rule) => `- ${rule}`),
327
807
  "",
328
- ...items.map((item) => item.agent_prompt),
808
+ ...access.lanes.map(lanePrompt),
329
809
  ""
330
810
  ].join("\n")
331
811
  };
332
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
+ }
333
823
  var PatternSchema = z2.object({
334
824
  name: z2.string().min(1),
335
825
  regex: z2.string().min(1)
@@ -473,42 +963,42 @@ function renderSarif(result) {
473
963
  2
474
964
  );
475
965
  }
476
- var SEVERITY_COLOR = {
477
- block: (s) => import_picocolors.default.red(s),
478
- high: (s) => import_picocolors.default.magenta(s),
479
- warn: (s) => import_picocolors.default.yellow(s),
480
- 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)
481
971
  };
482
972
  function renderTable(result) {
483
973
  const lines = [];
484
974
  lines.push(
485
- `${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(
486
976
  ` \u2014 ${result.filesScanned} files, ${result.rulesLoaded} rules`
487
977
  )}`
488
978
  );
489
979
  lines.push("");
490
980
  if (result.findings.length === 0) {
491
- lines.push(import_picocolors.default.green("\u2713 No findings."));
981
+ lines.push(import_picocolors2.default.green("\u2713 No findings."));
492
982
  } else {
493
983
  for (const f of result.findings) {
494
- const sev = SEVERITY_COLOR[f.finding.severity](
984
+ const sev = SEVERITY_COLOR2[f.finding.severity](
495
985
  f.finding.severity.toUpperCase().padEnd(5)
496
986
  );
497
- 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}`)}`);
498
988
  lines.push(` ${f.finding.title}`);
499
989
  const evidence = f.spans[0]?.evidence;
500
- if (evidence) lines.push(import_picocolors.default.dim(` evidence: ${evidence}`));
501
- 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}`);
502
992
  lines.push("");
503
993
  }
504
994
  const counts = /* @__PURE__ */ new Map();
505
995
  for (const f of result.findings) {
506
996
  counts.set(f.finding.severity, (counts.get(f.finding.severity) ?? 0) + 1);
507
997
  }
508
- const parts = [...counts.entries()].map(([sev, n]) => SEVERITY_COLOR[sev](`${n} ${sev}`));
509
- 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(", ")})`);
510
1000
  }
511
- 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)}`));
512
1002
  return lines.join("\n");
513
1003
  }
514
1004
  var DEP_FIELDS = [
@@ -692,12 +1182,6 @@ function checkNoLockfile(rule, ctx) {
692
1182
  }
693
1183
  return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
694
1184
  }
695
- var SEVERITY_RANK = {
696
- block: 0,
697
- high: 1,
698
- warn: 2,
699
- info: 3
700
- };
701
1185
  var SKIP_DIRS = /* @__PURE__ */ new Set([
702
1186
  "node_modules",
703
1187
  ".git",
@@ -706,6 +1190,8 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
706
1190
  "build",
707
1191
  "out",
708
1192
  "coverage",
1193
+ "target",
1194
+ ".gradle",
709
1195
  ".turbo",
710
1196
  ".vercel",
711
1197
  ".cache",
@@ -786,7 +1272,14 @@ function scan(target, options = {}) {
786
1272
  throw new Error(`${rule.id}: unknown builtin check "${rule.check.builtin}"`);
787
1273
  }
788
1274
  }
789
- 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));
790
1283
  findings.sort(
791
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)
792
1285
  );
@@ -834,7 +1327,12 @@ async function scanAsync(target, options = {}) {
834
1327
  }
835
1328
  }
836
1329
  result.findings = [...result.findings, ...dependencyFindings].filter(
837
- (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
+ )
838
1336
  );
839
1337
  result.findings.sort(
840
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)
@@ -843,6 +1341,22 @@ async function scanAsync(target, options = {}) {
843
1341
  result.networkSkipped = false;
844
1342
  return result;
845
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
+ }
846
1360
  function computeExitCode(findings, failOn) {
847
1361
  if (failOn === "never") return 0;
848
1362
  const threshold = SEVERITY_RANK[failOn];
@@ -896,13 +1410,21 @@ var pkg = JSON.parse(
896
1410
  readFileSync6(new URL("../package.json", import.meta.url), "utf8")
897
1411
  );
898
1412
  var FORMATS = ["table", "json", "sarif"];
1413
+ var ACCESS_FORMATS = ["table", "json"];
899
1414
  var FAIL_ON = ["block", "high", "warn", "never"];
900
- function assertOptions(opts) {
901
- if (opts.format && !FORMATS.includes(opts.format)) {
902
- 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(", ")})`);
903
1419
  process.exitCode = 2;
904
1420
  return false;
905
1421
  }
1422
+ return true;
1423
+ }
1424
+ function assertOptions(opts) {
1425
+ if (!assertChoice(opts.format, FORMATS, "format")) {
1426
+ return false;
1427
+ }
906
1428
  if (opts.failOn && !FAIL_ON.includes(opts.failOn)) {
907
1429
  console.error(`seamshield: unknown --fail-on "${opts.failOn}" (expected: ${FAIL_ON.join(", ")})`);
908
1430
  process.exitCode = 2;
@@ -934,6 +1456,20 @@ async function runScan(path, opts) {
934
1456
  process.exitCode = 2;
935
1457
  }
936
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
+ }
937
1473
  function writeAgentContext(target, kind) {
938
1474
  const body = [
939
1475
  "# SEAMSHIELD",
@@ -948,24 +1484,65 @@ function writeAgentContext(target, kind) {
948
1484
  ].join("\n");
949
1485
  if (kind === "cursor") {
950
1486
  const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
951
- mkdirSync(dirname3(out2), { recursive: true });
952
- writeFileSync(out2, body);
1487
+ mkdirSync3(dirname3(out2), { recursive: true });
1488
+ writeFileSync3(out2, body);
953
1489
  return out2;
954
1490
  }
955
1491
  const out = join7(target, "CLAUDE.md");
956
1492
  const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
957
1493
  const marker = "# SEAMSHIELD";
958
1494
  const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
959
- writeFileSync(out, next.endsWith("\n") ? next : `${next}
1495
+ writeFileSync3(out, next.endsWith("\n") ? next : `${next}
960
1496
  `);
961
1497
  return out;
962
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
+ }
963
1540
  function currentBin() {
964
1541
  return fileURLToPath2(import.meta.url);
965
1542
  }
966
1543
  function installGuard(target) {
967
1544
  const settingsPath = join7(target, ".claude", "settings.json");
968
- mkdirSync(dirname3(settingsPath), { recursive: true });
1545
+ mkdirSync3(dirname3(settingsPath), { recursive: true });
969
1546
  const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
970
1547
  const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
971
1548
  const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
@@ -976,7 +1553,7 @@ function installGuard(target) {
976
1553
  }
977
1554
  ];
978
1555
  settings.hooks = hooks;
979
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
1556
+ writeFileSync3(settingsPath, `${JSON.stringify(settings, null, 2)}
980
1557
  `);
981
1558
  return settingsPath;
982
1559
  }
@@ -1039,8 +1616,8 @@ function guardCheck() {
1039
1616
  }
1040
1617
  const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
1041
1618
  const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
1042
- mkdirSync(dirname3(abs), { recursive: true });
1043
- writeFileSync(abs, proposed.content);
1619
+ mkdirSync3(dirname3(abs), { recursive: true });
1620
+ writeFileSync3(abs, proposed.content);
1044
1621
  const result = scan(tempRoot, { network: "off" });
1045
1622
  rmSync(tempRoot, { recursive: true, force: true });
1046
1623
  const block = result.findings.find((f) => f.finding.severity === "block");
@@ -1056,8 +1633,8 @@ function guardCheck() {
1056
1633
  } catch (error) {
1057
1634
  const logPath = join7(process.cwd(), ".seamshield", "guard.log");
1058
1635
  try {
1059
- mkdirSync(dirname3(logPath), { recursive: true });
1060
- writeFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1636
+ mkdirSync3(dirname3(logPath), { recursive: true });
1637
+ writeFileSync3(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
1061
1638
  `, { flag: "a" });
1062
1639
  } catch {
1063
1640
  }
@@ -1069,7 +1646,23 @@ program.name("seamshield").description("Security scanner for AI-generated apps:
1069
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) => {
1070
1647
  return runScan(path, opts);
1071
1648
  });
1072
- 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;
1073
1666
  if (!existsSync2(path)) {
1074
1667
  console.error(`seamshield: path not found: ${path}`);
1075
1668
  process.exitCode = 2;
@@ -1077,12 +1670,24 @@ program.command("fix-plan").description("Write .seamshield/fix-plan.json with ag
1077
1670
  }
1078
1671
  const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
1079
1672
  const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
1080
- mkdirSync(dirname3(out), { recursive: true });
1081
- 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)}
1082
1675
  `);
1676
+ const markdownOut = writeMarkdownFixPlan(result, { agent: opts.agent });
1083
1677
  console.log(out);
1678
+ console.log(markdownOut);
1084
1679
  process.exitCode = result.exitCode;
1085
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
+ );
1086
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) => {
1087
1692
  const target = resolve2(path);
1088
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.2",
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: >
@@ -19,6 +19,9 @@ check:
19
19
  - ".cjs"
20
20
  basenames:
21
21
  - ".env*"
22
+ exclude:
23
+ basenames:
24
+ - "seamshield-release-gate.mjs"
22
25
  patterns:
23
26
  - name: next-public-secret-name
24
27
  regex: "NEXT_PUBLIC_[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|ADMIN)[A-Z0-9_]*"
@@ -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.