security: scaffold jpkg projects with strict defaults

Jaime Fournier <jaimef@linbsd.org>

e16b2a4b3b0b9b6a5e8672b14b83884b5201c7e9

diff --git a/docs/jpkg-guide.md b/docs/jpkg-guide.md
index 42f5308..8dd5617 100644
--- a/docs/jpkg-guide.md
+++ b/docs/jpkg-guide.md
@@ -95,6 +95,7 @@ Everything local works with zero infrastructure:
 ```sh
 mkdir demo && cd demo
 jpkg init @me/demo            # writes a minimal jpkg.sexp
+jpkg verify --strict          # validates manifest + committed empty lock
 jpkg pack                    # → me-demo-0.1.0.jpkg (reproducible)
 jpkg verify me-demo-0.1.0.jpkg
 jpkg verify --reproduce      # prints the deterministic artifact digest
@@ -125,10 +126,11 @@ always use the explicit form `jpkg add @lisp/jerboa-ssh`.
 Suppress the built-in default with `JPKG_NO_DEFAULT_REGISTRY=1` (e.g.
 to enforce that only your own registries are visible).
 
-Scaffold a fresh package with source and docs:
+Scaffold a fresh package with source, docs, lockfile, strict policy, and a
+CI template that runs `jpkg verify --strict`, `jpkg audit`, and `jpkg build`:
 
 ```sh
-jpkg new @me/hello           # creates hello/{jpkg.sexp,src/main.ss,README.md}
+jpkg new @me/hello
 cd hello && jpkg build       # sandboxed build + deterministic attestation
 ```
 
@@ -203,8 +205,8 @@ Global: `jpkg --help`, `jpkg --version`. Every command also works as
 ### Project lifecycle
 | Command | What it does |
 |---|---|
-| `jpkg init [NAME]` | Create `jpkg.sexp` (NAME defaults to `@local/<dir>`). |
-| `jpkg new NAME` | Scaffold a new package directory (manifest + `src/main.ss` + README). |
+| `jpkg init [NAME]` | Create `jpkg.sexp`, `jpkg.policy.sexp`, and an empty `jpkg.lock` (NAME defaults to `@local/<dir>`). |
+| `jpkg new NAME` | Scaffold a new package directory with manifest, strict policy, lockfile, `src/main.ss`, README, and `.build.yml`. |
 | `jpkg add PKG[@VERSION]` | Add a dependency (range), resolve, write lock, install. |
 | `jpkg remove PKG` | Drop a dependency, re-resolve, prune the environment. |
 | `jpkg install` | Install **exactly** `jpkg.lock` (no resolution). |
diff --git a/docs/kimi3-security-recommmendations.md b/docs/kimi3-security-recommmendations.md
index c62daaf..b35140c 100644
--- a/docs/kimi3-security-recommmendations.md
+++ b/docs/kimi3-security-recommmendations.md
@@ -846,6 +846,13 @@ The jpkg hardening exists but is opt-in (`JPKG_REQUIRE_SIGNATURES`,
 lockfile committed, and `jpkg audit` in their CI template. Update
 `jpkg-guide.md` quickstart to the secure default path.
 
+- **Status:** complete for generated jpkg projects. `jpkg init` now creates
+  `jpkg.sexp`, strict `jpkg.policy.sexp` requiring signatures and provenance,
+  and an empty `jpkg.lock` in the current directory. `jpkg new` scaffolds the
+  same policy/lock defaults plus `.build.yml` with `jpkg verify --strict`,
+  `jpkg audit`, and `jpkg build`. `docs/jpkg-guide.md` documents these
+  defaults in the quickstart and command table.
+
 ### K3-P2-02 — Dependency-audit parity and freshness
 **Serves:** G5. **Effort:** 3 days.
 
diff --git a/lib/std/pkg/commands.ss b/lib/std/pkg/commands.ss
index 7fe1faa..ee7c99f 100644
--- a/lib/std/pkg/commands.ss
+++ b/lib/std/pkg/commands.ss
@@ -26,6 +26,7 @@
                 artifact-info-digest artifact-info-size)
           (only (std pkg tarball) tar-entry-dir?)
           (only (std pkg lock)
+                lock-write-file
                 locked-package-name locked-package-version
                 locked-package-registry locked-package-link)
           (only (std pkg project)
@@ -98,10 +99,15 @@
         (jpkg-error "invalid package name ~s (want @scope/name, lowercase)" name))
       (when (file-exists? "jpkg.sexp")
         (jpkg-error "jpkg.sexp already exists (refusing to overwrite)"))
+      (when (file-exists? "jpkg.policy.sexp")
+        (jpkg-error "jpkg.policy.sexp already exists (refusing to overwrite)"))
+      (when (file-exists? "jpkg.lock")
+        (jpkg-error "jpkg.lock already exists (refusing to overwrite)"))
       (write-file-bytevector "jpkg.sexp" (string->utf8 (manifest-template name)))
+      (write-secure-project-defaults! ".")
       ;; template must always re-parse
       (parse-manifest-file "jpkg.sexp")
-      (say "created jpkg.sexp for ~a" name)
+      (say "created jpkg.sexp, jpkg.policy.sexp, and jpkg.lock for ~a" name)
       0))
 
   ;; ── new ────────────────────────────────────────────────────────────────
@@ -116,6 +122,28 @@
      "\n"
      "(main)\n"))
 
+  (def strict-policy-template
+    (string-append
+     ";; jpkg.policy.sexp — generated by jpkg; keep with jpkg.lock.\n"
+     "(policy\n"
+     "  (mode strict)\n"
+     "  (require (signatures) (provenance)))\n"))
+
+  (def ci-template
+    (string-append
+     "image: alpine/latest\n"
+     "tasks:\n"
+     "  - verify: |\n"
+     "      jpkg verify --strict\n"
+     "      jpkg audit\n"
+     "  - build: |\n"
+     "      jpkg build\n"))
+
+  (def (write-secure-project-defaults! dir)
+    (write-file-bytevector (path-concat dir "jpkg.policy.sexp")
+                           (string->utf8 strict-policy-template))
+    (lock-write-file (path-concat dir "jpkg.lock") '()))
+
   (def (cmd-new args)
     (unless (and (pair? args) (null? (cdr args)))
       (jpkg-error "usage: jpkg new NAME   (NAME like @scope/name)"))
@@ -131,16 +159,22 @@
         (mkdir-p (path-concat dir "src"))
         (write-file-bytevector (path-concat dir "jpkg.sexp")
                                (string->utf8 (manifest-template name)))
+        (write-secure-project-defaults! dir)
         (write-file-bytevector (path-concat dir "src/main.ss")
                                (string->utf8 main-ss-template))
         (write-file-bytevector
          (path-concat dir "README.md")
          (string->utf8 (string-append "# " name "\n")))
+        (write-file-bytevector (path-concat dir ".build.yml")
+                               (string->utf8 ci-template))
         (parse-manifest-file (path-concat dir "jpkg.sexp"))
         (say "created package ~a in ~a/" name dir)
         (say "  ~a/jpkg.sexp" dir)
+        (say "  ~a/jpkg.policy.sexp" dir)
+        (say "  ~a/jpkg.lock" dir)
         (say "  ~a/src/main.ss" dir)
         (say "  ~a/README.md" dir)
+        (say "  ~a/.build.yml" dir)
         0)))
 
   ;; ── pack ───────────────────────────────────────────────────────────────
diff --git a/tests/test-jpkg-commands.ss b/tests/test-jpkg-commands.ss
index 3397ab1..3bbf158 100644
--- a/tests/test-jpkg-commands.ss
+++ b/tests/test-jpkg-commands.ss
@@ -44,9 +44,14 @@
 
 ;; ── init ────────────────────────────────────────────────────────────────
 
-(check "init-creates-manifest"
+(check "init-creates-secure-project-files"
        (let ([r (run-jpkg '("init" "@test/alpha"))])
-         (and (= (car r) 0) (file-exists? "jpkg.sexp"))))
+         (and (= (car r) 0)
+              (file-exists? "jpkg.sexp")
+              (file-exists? "jpkg.policy.sexp")
+              (file-exists? "jpkg.lock")
+              (s-contains? (utf8->string (read-file-bytevector "jpkg.policy.sexp"))
+                           "(require (signatures) (provenance))"))))
 
 (check "init-refuses-overwrite"
        (let ([r (run-jpkg '("init" "@test/alpha"))])
@@ -70,12 +75,17 @@
 
 ;; ── new ─────────────────────────────────────────────────────────────────
 
-(check "new-scaffolds"
+(check "new-scaffolds-secure-defaults"
        (let ([r (run-jpkg '("new" "@test/beta"))])
          (and (= (car r) 0)
               (file-exists? "beta/jpkg.sexp")
+              (file-exists? "beta/jpkg.policy.sexp")
+              (file-exists? "beta/jpkg.lock")
               (file-exists? "beta/src/main.ss")
-              (file-exists? "beta/README.md"))))
+              (file-exists? "beta/README.md")
+              (file-exists? "beta/.build.yml")
+              (s-contains? (utf8->string (read-file-bytevector "beta/.build.yml"))
+                           "jpkg audit"))))
 
 (check "new-refuses-existing-dir"
        (= (car (run-jpkg '("new" "@test/beta"))) 1))