@natjswenson/devlog 0.1.5 → 0.1.6

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/SKILL.md CHANGED
@@ -47,6 +47,25 @@ If the file does not exist or cannot be parsed, stop and tell the user:
47
47
 
48
48
  Validate that `targetRepo`, `gitAuthor`, `githubUser`, and `projects` (non-empty array) are all present. Each project must have `key`, `path`, and `remote`.
49
49
 
50
+ ### Step 0.5: SECURITY — validate config values before using them in shell commands
51
+
52
+ **Critical:** every value below gets interpolated into shell commands. If any value contains shell metacharacters or breaks the expected shape, **STOP** and tell the user their config is malformed. Do not "fix" it — refuse to run.
53
+
54
+ | Field | Required pattern |
55
+ |---|---|
56
+ | `targetRepo` | Matches `^[a-zA-Z0-9][a-zA-Z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$` (owner/repo, no leading dash) |
57
+ | `branch` (optional) | Matches `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$` (no leading dash, no `..`); defaults to `main` |
58
+ | `gitAuthor` | Must NOT contain any of: `;` `&` `|` `` ` `` `$` `(` `)` `<` `>` `{` `}` `*` `?` `!` `#` `~` `"` `\` newline, carriage return |
59
+ | `githubUser` | Matches `^[a-zA-Z0-9][a-zA-Z0-9-]*$` |
60
+ | `projects[].key` | Matches `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` AND must not contain `..` |
61
+ | `projects[].path` | Must NOT contain shell metacharacters (same set as gitAuthor) AND must point to an existing directory |
62
+ | `projects[].remote` | Same pattern as `targetRepo` |
63
+
64
+ If any field fails validation, stop with:
65
+ > Config field `<field>` failed security validation: `<value>`. Edit `~/.claude/skills/devlog/config.json` and retry.
66
+
67
+ Once validated, the values are safe to interpolate into the shell commands below. Even so, **prefer `git -C <path>` form over `cd <path> && git ...`** (reduces shell-escape complexity) and **use the Write tool, not bash heredocs, when writing JSON or markdown files** (avoids accidentally re-injecting attacker-controlled content into shell).
68
+
50
69
  ## Step 1: Determine scope
51
70
 
52
71
  - If the user passed a project argument (e.g. `/devlog myproject`), filter `projects` to that one. If the key is not in the registry, list available keys and stop.
@@ -54,10 +73,10 @@ Validate that `targetRepo`, `gitAuthor`, `githubUser`, and `projects` (non-empty
54
73
 
55
74
  ## Step 2: Gather today's commits
56
75
 
57
- For each project in scope, run:
76
+ For each project in scope, run (use `git -C` to avoid `cd` shell-composition):
58
77
 
59
78
  ```bash
60
- cd <project.path> && git log --author="<config.gitAuthor>" --since="midnight" --format="%H|%s|%D" --all
79
+ git -C <project.path> log --author=<config.gitAuthor> --since=midnight --format=%H|%s|%D --all
61
80
  ```
62
81
 
63
82
  If no commits are found for a project, skip it. If no commits are found across all projects, inform the user and stop.
@@ -67,8 +86,8 @@ If no commits are found for a project, skip it. If no commits are found across a
67
86
  For each commit, check if it's on the `main` branch and if the remote is public:
68
87
 
69
88
  ```bash
70
- cd <project.path> && git remote get-url origin
71
- git branch --contains <hash> -r 2>/dev/null | grep -q 'origin/main'
89
+ git -C <project.path> remote get-url origin
90
+ git -C <project.path> branch --contains <hash> -r 2>/dev/null | grep -q 'origin/main'
72
91
  ```
73
92
 
74
93
  - If the remote URL matches `<project.remote>` (i.e. `github.com/<project.remote>` or the SSH equivalent) and the commit is on `origin/main`, it's a public commit — include a link using `https://github.com/<project.remote>/commit/<hash>`.
@@ -125,33 +144,38 @@ gh api repos/<config.targetRepo>/contents/<project.key>/YYYY-MM-DD.md --jq '.con
125
144
 
126
145
  ## Step 6: Push to GitHub
127
146
 
128
- Clone the repo once, write all project entries, then push:
147
+ Clone the repo once, write all project entries, then push.
148
+
149
+ **Important:** Claude Code's bash tool runs each invocation in a fresh shell — variables don't persist across calls. Use a single temp path you compute once and pass as an absolute path to every subsequent command. Do NOT rely on `$TMPDIR` or any other shell variable surviving between bash calls.
150
+
151
+ ```bash
152
+ # Step 6.1: create temp dir, capture absolute path (use this exact path
153
+ # in every subsequent command — do not reference $TMPDIR after this call)
154
+ mktemp -d
155
+ # → record the printed path, e.g. /var/folders/.../tmp.abc123
156
+
157
+ # Step 6.2: clone (use --depth=1 to limit blast radius if remote is huge)
158
+ git -C <abs-tmp-path> clone --depth=1 https://github.com/<config.targetRepo>.git
159
+ ```
160
+
161
+ Write entries and manifest using the **Write tool** (not bash heredocs — avoids re-injecting content into shell):
162
+
163
+ - For each project with commits:
164
+ - Path: `<abs-tmp-path>/<repo-name>/<project.key>/YYYY-MM-DD.md`
165
+ - Path: `<abs-tmp-path>/<repo-name>/<project.key>/manifest.json` — read with the Read tool, mutate the entries array (newest first), write back
166
+ - Entry object: `{ "date": "YYYY-MM-DD", "file": "YYYY-MM-DD.md", "title": "...", "summary": "..." }`
167
+ - If appending to existing entry, update title/summary only if changed
168
+ - If manifest doesn't exist, create it as `{ "entries": [...] }`
169
+
170
+ Then commit and push:
129
171
 
130
172
  ```bash
131
- # Clone to temp directory
132
- TMPDIR=$(mktemp -d)
133
- cd "$TMPDIR"
134
- git clone https://github.com/<config.targetRepo>.git
135
- cd $(basename <config.targetRepo>)
136
-
137
- # For each project with commits:
138
- # - Create directory if needed: mkdir -p <project.key>/
139
- # - Write the entry file to <project.key>/YYYY-MM-DD.md
140
- # - Update <project.key>/manifest.json
141
- # - Read manifest, add/update entry in entries array (newest first)
142
- # - Entry object: { "date": "YYYY-MM-DD", "file": "YYYY-MM-DD.md", "title": "...", "summary": "..." }
143
- # - If appending to existing entry, update title/summary only if changed
144
- # - If manifest doesn't exist, create it as { "entries": [...] }
145
-
146
- # Stage all changed project directories
147
- git add .
148
-
149
- # Single commit covering all projects
150
- git commit -m "devlog: add entries for YYYY-MM-DD"
151
- git push origin <config.branch || 'main'>
152
-
153
- # Cleanup
154
- rm -rf "$TMPDIR"
173
+ git -C <abs-tmp-path>/<repo-name> add .
174
+ git -C <abs-tmp-path>/<repo-name> commit -m "devlog: add entries for YYYY-MM-DD"
175
+ git -C <abs-tmp-path>/<repo-name> push origin <config.branch || 'main'>
176
+
177
+ # Cleanup — pass the absolute path explicitly
178
+ rm -rf <abs-tmp-path>
155
179
  ```
156
180
 
157
181
  ## Step 7: Confirm
package/bin/devlog.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, execSync } from 'node:child_process';
2
+ import { spawn, spawnSync, execSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join, resolve, basename } from 'node:path';
@@ -8,6 +8,8 @@ import { createRequire } from 'node:module';
8
8
  import prompts from 'prompts';
9
9
  import kleur from 'kleur';
10
10
 
11
+ const SHELL_METACHARS = /[;&|`$()<>{}*?!#~"\\\n\r]/;
12
+
11
13
  const require = createRequire(import.meta.url);
12
14
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
15
  const SKILL_SRC = join(PACKAGE_ROOT, 'SKILL.md');
@@ -29,6 +31,8 @@ function readPackageVersion() {
29
31
  return pkg.version;
30
32
  }
31
33
 
34
+ // Runs a hardcoded shell command (no user input). Use tryExecArgs for any
35
+ // command whose arguments include user-supplied values.
32
36
  function tryExec(cmd) {
33
37
  try {
34
38
  return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
@@ -37,6 +41,17 @@ function tryExec(cmd) {
37
41
  }
38
42
  }
39
43
 
44
+ // argv-style invocation; no shell, so user-supplied args cannot inject.
45
+ function tryExecArgs(cmd, args) {
46
+ try {
47
+ const r = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
48
+ if (r.status !== 0) return null;
49
+ return (r.stdout || '').trim();
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
40
55
  function expandHome(p) {
41
56
  if (!p) return p;
42
57
  if (p === '~') return homedir();
@@ -76,7 +91,7 @@ function detectGitName() {
76
91
  }
77
92
 
78
93
  function detectProjectRemote(path) {
79
- const url = tryExec(`git -C "${path}" remote get-url origin`);
94
+ const url = tryExecArgs('git', ['-C', path, 'remote', 'get-url', 'origin']);
80
95
  if (!url) return null;
81
96
  const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
82
97
  return m ? m[1] : null;
@@ -109,21 +124,25 @@ async function cmdInit() {
109
124
  name: 'gitAuthor',
110
125
  message: 'Your name (used to filter `git log --author`):',
111
126
  initial: defaults.gitAuthor,
112
- validate: (v) => v.trim().length > 0 || 'Required',
127
+ validate: (v) => {
128
+ if (v.trim().length === 0) return 'Required';
129
+ if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
130
+ return true;
131
+ },
113
132
  },
114
133
  {
115
134
  type: 'text',
116
135
  name: 'githubUser',
117
136
  message: 'Your GitHub username:',
118
137
  initial: defaults.githubUser,
119
- validate: (v) => /^[a-z0-9-]+$/i.test(v.trim()) || 'Invalid username',
138
+ validate: (v) => /^[a-z0-9][a-z0-9-]*$/i.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
120
139
  },
121
140
  {
122
141
  type: 'text',
123
142
  name: 'targetRepoName',
124
143
  message: 'Name of the repo where dev logs will be published:',
125
144
  initial: defaults.targetRepoName,
126
- validate: (v) => /^[a-z0-9._-]+$/i.test(v.trim()) || 'Invalid repo name',
145
+ validate: (v) => /^[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
127
146
  },
128
147
  {
129
148
  type: 'confirm',
@@ -143,21 +162,29 @@ async function cmdInit() {
143
162
  name: 'path',
144
163
  message: 'Project absolute path:',
145
164
  initial: cwd,
146
- validate: (v) => existsSync(expandHome(v)) || 'Path does not exist',
165
+ validate: (v) => {
166
+ if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
167
+ return existsSync(expandHome(v)) || 'Path does not exist';
168
+ },
147
169
  },
148
170
  {
149
171
  type: 'text',
150
172
  name: 'key',
151
173
  message: 'Project key (used as dev-log subdir name):',
152
174
  initial: (prev) => basename(expandHome(prev || cwd)),
153
- validate: (v) => /^[a-z0-9._-]+$/i.test(v.trim()) || 'Invalid key',
175
+ validate: (v) => {
176
+ const t = v.trim();
177
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(t)) return 'Invalid key (must start with letter/digit, alphanumeric + ._- only)';
178
+ if (t.includes('..')) return 'Invalid key (no `..`)';
179
+ return true;
180
+ },
154
181
  },
155
182
  {
156
183
  type: 'text',
157
184
  name: 'remote',
158
185
  message: 'Project GitHub remote (<owner>/<repo>):',
159
186
  initial: (_prev, values) => detectProjectRemote(expandHome(values.path)) || cwdRemote || `${answers.githubUser}/${basename(expandHome(values.path))}`,
160
- validate: (v) => /^[\w.-]+\/[\w.-]+$/.test(v.trim()) || 'Expected <owner>/<repo>',
187
+ validate: (v) => /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash',
161
188
  },
162
189
  ], { onCancel: () => process.exit(1) });
163
190
  }
@@ -191,20 +218,19 @@ async function cmdInit() {
191
218
 
192
219
  log.info('');
193
220
 
194
- const repoExists = tryExec(`gh repo view ${targetRepo} --json name`) !== null;
221
+ const repoExists = tryExecArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
195
222
  if (repoExists) {
196
223
  log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
197
224
  } else {
198
225
  log.step(`Creating github.com/${targetRepo}...`);
199
- try {
200
- execSync(`gh repo create ${targetRepo} --public --description "Daily dev log" --add-readme`, {
201
- stdio: 'inherit',
202
- });
203
- log.ok('Repo created');
204
- } catch {
226
+ const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], {
227
+ stdio: 'inherit',
228
+ });
229
+ if (r.status !== 0) {
205
230
  log.err('Failed to create repo. Check `gh` permissions.');
206
231
  process.exit(1);
207
232
  }
233
+ log.ok('Repo created');
208
234
  }
209
235
 
210
236
  if (!existsSync(CONFIG_DIR)) {
@@ -8,8 +8,12 @@ import './DevLogPage.css';
8
8
  const ENTRIES_PER_PAGE = 10;
9
9
 
10
10
  function formatDate(dateStr) {
11
- const [year, month, day] = dateStr.split('-');
12
- const date = new Date(year, month - 1, day);
11
+ if (typeof dateStr !== 'string') return '';
12
+ const parts = dateStr.split('-');
13
+ if (parts.length !== 3) return '';
14
+ const [year, month, day] = parts;
15
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
16
+ if (isNaN(date.getTime())) return '';
13
17
  return date.toLocaleDateString('en-US', {
14
18
  month: 'short',
15
19
  day: 'numeric',
@@ -1,24 +1,44 @@
1
1
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
2
  import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
3
3
 
4
+ // Allowlist of frontmatter keys we recognize. Anything else is ignored —
5
+ // prevents prototype-pollution via crafted keys like `__proto__`.
6
+ const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
7
+
4
8
  /**
5
9
  * Parse YAML-ish frontmatter from a markdown string.
6
10
  * Returns { metadata: { title, date, project, summary }, body: string }
7
11
  */
8
12
  function parseFrontmatter(raw) {
9
13
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
10
- if (!match) return { metadata: {}, body: raw };
11
-
12
- const frontmatter = match[1];
13
- const body = match[2];
14
- const metadata = {};
14
+ // Use Object.create(null) so the returned object has no prototype chain.
15
+ const metadata = Object.create(null);
16
+ if (!match) return { metadata, body: raw };
15
17
 
16
- for (const line of frontmatter.split('\n')) {
18
+ for (const line of match[1].split('\n')) {
17
19
  const m = line.match(/^(\w+)\s*:\s*"?([^"]*)"?\s*$/);
18
- if (m) metadata[m[1]] = m[2].trim();
20
+ if (m && FRONTMATTER_KEYS.has(m[1])) metadata[m[1]] = m[2].trim();
19
21
  }
20
22
 
21
- return { metadata, body };
23
+ return { metadata, body: match[2] };
24
+ }
25
+
26
+ // Schema validation for fetched manifest. Reject anything that isn't shaped
27
+ // like { entries: [{ date, file, title, summary }, ...] } so a hostile commit
28
+ // to the dev-log repo can't crash the page.
29
+ function validateManifest(data) {
30
+ if (!data || typeof data !== 'object') return null;
31
+ if (!Array.isArray(data.entries)) return null;
32
+ const entries = [];
33
+ for (const e of data.entries) {
34
+ if (!e || typeof e !== 'object') continue;
35
+ const { date, file, title, summary } = e;
36
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
37
+ if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
38
+ if (typeof title !== 'string' || typeof summary !== 'string') continue;
39
+ entries.push({ date, file, title, summary });
40
+ }
41
+ return { entries };
22
42
  }
23
43
 
24
44
  /**
@@ -51,7 +71,9 @@ export function useDevLogEntries(project, configOverride) {
51
71
  setError(null);
52
72
 
53
73
  try {
54
- const url = `${config.baseUrl}/${project}/manifest.json`;
74
+ // Encode project key to neutralize any path-traversal characters
75
+ // (the project key is allowlisted upstream, but defense-in-depth).
76
+ const url = `${config.baseUrl}/${encodeURIComponent(project)}/manifest.json`;
55
77
  const res = await fetch(url);
56
78
  if (!res.ok) {
57
79
  if (res.status === 404) {
@@ -61,7 +83,9 @@ export function useDevLogEntries(project, configOverride) {
61
83
  throw new Error(`Failed to fetch manifest (${res.status})`);
62
84
  }
63
85
  const data = await res.json();
64
- setEntries(data.entries || []);
86
+ const validated = validateManifest(data);
87
+ if (!validated) throw new Error('Manifest failed schema validation');
88
+ setEntries(validated.entries);
65
89
  } catch (err) {
66
90
  setError(err.message);
67
91
  } finally {
@@ -76,8 +100,15 @@ export function useDevLogEntries(project, configOverride) {
76
100
  const fetchEntryContent = useCallback(async (filename) => {
77
101
  if (contentCache.current.has(filename)) return;
78
102
 
103
+ // Final filename gate (manifest validation already enforces the same
104
+ // pattern — keep this here so any consumer calling fetchEntryContent
105
+ // directly is also protected).
106
+ if (typeof filename !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(filename)) {
107
+ return;
108
+ }
109
+
79
110
  try {
80
- const url = `${config.baseUrl}/${project}/${filename}`;
111
+ const url = `${config.baseUrl}/${encodeURIComponent(project)}/${filename}`;
81
112
  const res = await fetch(url);
82
113
  if (!res.ok) throw new Error(`Failed to fetch entry (${res.status})`);
83
114
  const raw = await res.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Daily dev log generator — Claude Code skill + preview app for publishing git-based dev logs to your site",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
package/preview/App.jsx CHANGED
@@ -11,7 +11,18 @@ const isDemo = !owner || !repo;
11
11
 
12
12
  let realProjects = [];
13
13
  try {
14
- realProjects = projectsRaw ? JSON.parse(projectsRaw) : [];
14
+ const parsed = projectsRaw ? JSON.parse(projectsRaw) : [];
15
+ // Schema-validate: must be array of {key, label?} where key matches a safe
16
+ // allowlist. Anything else gets dropped silently.
17
+ if (Array.isArray(parsed)) {
18
+ for (const p of parsed) {
19
+ if (!p || typeof p !== 'object') continue;
20
+ if (typeof p.key !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(p.key)) continue;
21
+ if (p.key.includes('..')) continue;
22
+ const label = typeof p.label === 'string' ? p.label : p.key;
23
+ realProjects.push({ key: p.key, label });
24
+ }
25
+ }
15
26
  } catch {
16
27
  realProjects = [];
17
28
  }
package/preview/main.jsx CHANGED
@@ -4,7 +4,10 @@ import App from './App.jsx';
4
4
  import { installDemoFetch } from './demo.js';
5
5
  import './preview.css';
6
6
 
7
- if (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO) {
7
+ // Gate the global window.fetch override behind DEV. In production builds
8
+ // (e.g. when adopters deploy the preview directory standalone), demo content
9
+ // must NOT silently intercept fetches — show the empty-state UX instead.
10
+ if (import.meta.env.DEV && (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO)) {
8
11
  installDemoFetch();
9
12
  }
10
13
 
@@ -19,8 +19,16 @@ export default defineConfig({
19
19
  'style-to-js',
20
20
  ],
21
21
  },
22
+ // Bind preview server to localhost only — the dev server has no auth and
23
+ // serves transformed source modules. Don't expose it to LAN by default.
24
+ // (npm audit flags moderate CVEs in dev-server CORS handling for any
25
+ // deployment that allows cross-origin reads; localhost-binding is
26
+ // defense-in-depth on top of vite's own patches.)
22
27
  server: {
28
+ host: 'localhost',
23
29
  port: 5173,
24
30
  open: true,
31
+ strictPort: false,
32
+ cors: false,
25
33
  },
26
34
  });