Strengthen credential security rules after violation

This commit is contained in:
Hoid 2026-02-09 00:11:27 +00:00
parent 66423cf66b
commit faff102d34
7 changed files with 136 additions and 29 deletions

View file

@ -48,10 +48,14 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u
- `trash` > `rm` (recoverable beats gone forever) - `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask. - When in doubt, ask.
### 🔑 Credentials ### 🔑 Credentials — HARD RULES
- **Never read credential files.** Not even to "verify" or "check" them. - **NEVER read credential files.** Not with `cat`, `read`, `exec`, `node -e`, or ANY tool. Not even to "debug", "verify", "check format", or "count lines". NO EXCEPTIONS.
- **NEVER use tools that would display file contents** on any file in `.credentials/`. This includes `grep`, `head`, `tail`, `cat -A`, `wc`, or any command that could leak values in output.
- **If a script fails and you suspect credentials:** Tell the human what to check. Do NOT look yourself.
- **If you need to know what keys exist:** You wrote the placeholder file — check git history or TOOLS.md, not the live file.
- When setting up a new integration, create `.credentials/service.env` with **placeholder values** and let the human fill them in. - When setting up a new integration, create `.credentials/service.env` with **placeholder values** and let the human fill them in.
- Scripts source credentials at runtime — you don't need to see them. - Scripts source credentials at runtime — you don't need to see them.
- **Violation of these rules is a serious breach of trust.** No excuse is valid.
- Example placeholder file: - Example placeholder file:
``` ```
SERVICE_URL=https://example.com SERVICE_URL=https://example.com

View file

@ -79,7 +79,7 @@ audiobooks stats # Listening stats overview
audiobooks libraries # List libraries audiobooks libraries # List libraries
``` ```
- Credentials: `.credentials/audiobookshelf.env` - Credentials: `.credentials/services.env`
- URL: `https://audiobooks.cloonar.com` - URL: `https://audiobooks.cloonar.com`
- Output is tab-separated for minimal tokens - Output is tab-separated for minimal tokens
- Use during wind-down to suggest continuing audiobook - Use during wind-down to suggest continuing audiobook
@ -101,7 +101,7 @@ jellyfin stats # Watch statistics overview
jellyfin libraries # List libraries jellyfin libraries # List libraries
``` ```
- Credentials: `.credentials/jellyfin.env` (user-scoped token for `tv`) - Credentials: `.credentials/services.env` (user-scoped token for `tv`)
- URL: `https://jellyfin.cloonar.com` - URL: `https://jellyfin.cloonar.com`
- Output is tab-separated for minimal tokens - Output is tab-separated for minimal tokens
- Use during wind-down to suggest specific shows/movies - Use during wind-down to suggest specific shows/movies
@ -126,7 +126,7 @@ forgejo raw <endpoint> # Raw API call
- URL: `https://git.cloonar.com` - URL: `https://git.cloonar.com`
- User: `openclawd` (read-only) - User: `openclawd` (read-only)
- Token stored in `.credentials/forgejo.env` - Credentials: `.credentials/services.env`
## CalDAV Calendar Access ## CalDAV Calendar Access

View file

@ -2,7 +2,9 @@
# AI News RSS helper - aggregates multiple AI-focused feeds # AI News RSS helper - aggregates multiple AI-focused feeds
set -e set -e
FIVEFILTERS_URL="https://fivefilters.cloonar.com" CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}"
source "$CRED_FILE"
SEEN_FILE="${AINEWS_SEEN_FILE:-$HOME/clawd/memory/ainews-seen.txt}" SEEN_FILE="${AINEWS_SEEN_FILE:-$HOME/clawd/memory/ainews-seen.txt}"
CURL="curl -skL" CURL="curl -skL"

View file

@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const https = require('https'); const https = require('https');
const CRED_PATH = path.join(__dirname, '..', '.credentials', 'audiobookshelf.env'); const CRED_PATH = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env');
function loadCreds() { function loadCreds() {
const raw = fs.readFileSync(CRED_PATH, 'utf8'); const raw = fs.readFileSync(CRED_PATH, 'utf8');

View file

@ -2,7 +2,9 @@
# Der Standard RSS helper - fetches via internal fivefilters # Der Standard RSS helper - fetches via internal fivefilters
set -e set -e
FIVEFILTERS_URL="https://fivefilters.cloonar.com" CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}"
source "$CRED_FILE"
RSS_SOURCE="https://www.derstandard.at/rss" RSS_SOURCE="https://www.derstandard.at/rss"
SEEN_FILE="${DERSTANDARD_SEEN_FILE:-$HOME/clawd/memory/derstandard-seen.txt}" SEEN_FILE="${DERSTANDARD_SEEN_FILE:-$HOME/clawd/memory/derstandard-seen.txt}"

View file

@ -2,10 +2,10 @@
# Forgejo CLI helper - token-efficient output # Forgejo CLI helper - token-efficient output
set -e set -e
FORGEJO_URL="https://git.cloonar.com" CRED_FILE="${CRED_FILE:-$(dirname "$0")/../.credentials/services.env}"
TOKEN="03e35afac87cd3d8db7d4a186714dacf584c01f7" source "$CRED_FILE"
CURL="curl -sk -H \"Authorization: token ${TOKEN}\"" CURL="curl -sk -H \"Authorization: token ${FORGEJO_TOKEN}\""
usage() { usage() {
cat <<EOF cat <<EOF

View file

@ -4,30 +4,130 @@ const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// Load credentials // Load credentials from unified services.env
const envFile = fs.readFileSync(path.join(__dirname, '..', '.credentials', 'jellyfin.env'), 'utf8'); const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env');
const envFile = fs.readFileSync(credFile, 'utf8');
const env = {}; const env = {};
envFile.split('\n').forEach(l => { const [k, ...v] = l.split('='); if (k && v.length) env[k.trim()] = v.join('=').trim(); }); envFile.split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; });
const BASE = env.JELLYFIN_URL; const BASE = env.JELLYFIN_URL;
const API_KEY = env.JELLYFIN_API_KEY; const TOKEN_CACHE = path.join(__dirname, '..', '.credentials', '.jellyfin-token.json');
const USER_ID = env.JELLYFIN_USER_ID;
function api(endpoint) { function request(method, endpoint, body) {
const sep = endpoint.includes('?') ? '&' : '?';
const url = `${BASE}${endpoint}${sep}api_key=${API_KEY}`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
https.get(url, res => { const sep = endpoint.includes('?') ? '&' : '?';
const u = new URL(`${BASE}${endpoint}`);
const opts = {
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
method, headers: {}
};
if (body) {
const data = JSON.stringify(body);
opts.headers['Content-Type'] = 'application/json';
opts.headers['Content-Length'] = Buffer.byteLength(data);
}
const req = https.request(opts, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(d) }); }
catch { resolve({ status: res.statusCode, data: d }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function authenticate() {
// Check cache first
try {
const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf8'));
if (cached.token && cached.userId) {
// Verify token still works
const test = await apiWith(cached.token, cached.userId, `/Users/${cached.userId}`);
if (test) return cached;
}
} catch {}
// Authenticate
const res = await request('POST', '/Users/AuthenticateByName', {
Username: env.JELLYFIN_USER,
Pw: env.JELLYFIN_PASS
});
// Add auth header for the request
const authRes = await new Promise((resolve, reject) => {
const body = JSON.stringify({ Username: env.JELLYFIN_USER, Pw: env.JELLYFIN_PASS });
const u = new URL(`${BASE}/Users/AuthenticateByName`);
const req = https.request({
hostname: u.hostname, port: u.port || 443, path: u.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Emby-Authorization': 'MediaBrowser Client="Hoid", Device="CLI", DeviceId="hoid-cli-1", Version="1.0"'
}
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(d) }); }
catch { resolve({ status: res.statusCode, data: d }); }
});
});
req.on('error', reject);
req.write(body);
req.end();
});
if (authRes.status !== 200 || !authRes.data.AccessToken) {
console.error(`Auth failed (${authRes.status}):`, typeof authRes.data === 'string' ? authRes.data : JSON.stringify(authRes.data).slice(0, 200));
process.exit(1);
}
const result = { token: authRes.data.AccessToken, userId: authRes.data.User.Id };
fs.writeFileSync(TOKEN_CACHE, JSON.stringify(result));
return result;
}
function apiWith(token, userId, endpoint) {
const sep = endpoint.includes('?') ? '&' : '?';
const url = `${BASE}${endpoint}`;
return new Promise((resolve, reject) => {
const u = new URL(url);
https.get({
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
headers: { 'X-Emby-Token': token }
}, res => {
let data = ''; let data = '';
res.on('data', c => data += c); res.on('data', c => data += c);
res.on('end', () => { res.on('end', () => {
if (res.statusCode === 401) return resolve(null);
try { resolve(JSON.parse(data)); } catch { resolve(data); } try { resolve(JSON.parse(data)); } catch { resolve(data); }
}); });
}).on('error', reject); }).on('error', reject);
}); });
} }
function getUserId() { return USER_ID; } let _auth;
async function api(endpoint) {
if (!_auth) _auth = await authenticate();
const result = await apiWith(_auth.token, _auth.userId, endpoint);
if (result === null) {
// Token expired, re-auth
try { fs.unlinkSync(TOKEN_CACHE); } catch {}
_auth = await authenticate();
return apiWith(_auth.token, _auth.userId, endpoint);
}
return result;
}
async function getUserId() {
if (!_auth) _auth = await authenticate();
return _auth.userId;
}
function dur(ticks) { function dur(ticks) {
if (!ticks) return ''; if (!ticks) return '';
@ -48,11 +148,10 @@ const commands = {
async resume(limit = 10) { async resume(limit = 10) {
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items/Resume?Limit=${limit}&Fields=Overview,RunTimeTicks&MediaTypes=Video`); const r = await api(`/Users/${uid}/Items/Resume?Limit=${limit}&Fields=Overview,RunTimeTicks&MediaTypes=Video`);
if (!r.Items.length) return console.log('Nothing in progress'); if (!r.Items || !r.Items.length) return console.log('Nothing in progress');
console.log('TITLE\tEPISODE\tPROGRESS\tDURATION'); console.log('TITLE\tEPISODE\tPROGRESS\tDURATION');
r.Items.forEach(i => { r.Items.forEach(i => {
const show = i.SeriesName || ''; const show = i.SeriesName || '';
const name = show ? `${i.Name}` : i.Name;
console.log([show || i.Name, show ? i.Name : '', pct(i.UserData, i.RunTimeTicks), dur(i.RunTimeTicks)].join('\t')); console.log([show || i.Name, show ? i.Name : '', pct(i.UserData, i.RunTimeTicks), dur(i.RunTimeTicks)].join('\t'));
}); });
}, },
@ -60,7 +159,7 @@ const commands = {
async recent(limit = 10) { async recent(limit = 10) {
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items/Latest?Limit=${limit}&Fields=RunTimeTicks`); const r = await api(`/Users/${uid}/Items/Latest?Limit=${limit}&Fields=RunTimeTicks`);
if (!r.length) return console.log('No recent items'); if (!r || !r.length) return console.log('No recent items');
console.log('TYPE\tTITLE\tNAME\tYEAR\tDURATION'); console.log('TYPE\tTITLE\tNAME\tYEAR\tDURATION');
r.forEach(i => { r.forEach(i => {
console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || '', dur(i.RunTimeTicks)].join('\t')); console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || '', dur(i.RunTimeTicks)].join('\t'));
@ -70,7 +169,7 @@ const commands = {
async watched(limit = 20) { async watched(limit = 20) {
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items?Limit=${limit}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Recursive=true&IncludeItemTypes=Movie,Episode&Fields=RunTimeTicks,DateLastMediaAdded`); const r = await api(`/Users/${uid}/Items?Limit=${limit}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Recursive=true&IncludeItemTypes=Movie,Episode&Fields=RunTimeTicks,DateLastMediaAdded`);
if (!r.Items.length) return console.log('No watched items'); if (!r.Items || !r.Items.length) return console.log('No watched items');
console.log('TYPE\tTITLE\tEPISODE\tYEAR'); console.log('TYPE\tTITLE\tEPISODE\tYEAR');
r.Items.forEach(i => { r.Items.forEach(i => {
console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t')); console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t'));
@ -80,7 +179,7 @@ const commands = {
async shows() { async shows() {
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`); const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`);
if (!r.Items.length) return console.log('No shows'); if (!r.Items || !r.Items.length) return console.log('No shows');
console.log('TITLE\tYEAR\tSTATUS'); console.log('TITLE\tYEAR\tSTATUS');
r.Items.forEach(i => { r.Items.forEach(i => {
console.log([i.Name, i.ProductionYear || '', pct(i.UserData, i.RunTimeTicks) || 'unwatched'].join('\t')); console.log([i.Name, i.ProductionYear || '', pct(i.UserData, i.RunTimeTicks) || 'unwatched'].join('\t'));
@ -90,7 +189,7 @@ const commands = {
async movies() { async movies() {
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`); const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`);
if (!r.Items.length) return console.log('No movies'); if (!r.Items || !r.Items.length) return console.log('No movies');
console.log('TITLE\tYEAR\tDURATION\tSTATUS'); console.log('TITLE\tYEAR\tDURATION\tSTATUS');
r.Items.forEach(i => { r.Items.forEach(i => {
console.log([i.Name, i.ProductionYear || '', dur(i.RunTimeTicks), pct(i.UserData, i.RunTimeTicks) || '-'].join('\t')); console.log([i.Name, i.ProductionYear || '', dur(i.RunTimeTicks), pct(i.UserData, i.RunTimeTicks) || '-'].join('\t'));
@ -101,7 +200,7 @@ const commands = {
if (!query) { console.error('Usage: jellyfin search <query>'); process.exit(1); } if (!query) { console.error('Usage: jellyfin search <query>'); process.exit(1); }
const uid = await getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Items?SearchTerm=${encodeURIComponent(query)}&Recursive=true&IncludeItemTypes=Movie,Series,Episode&Limit=10&Fields=RunTimeTicks`); const r = await api(`/Users/${uid}/Items?SearchTerm=${encodeURIComponent(query)}&Recursive=true&IncludeItemTypes=Movie,Series,Episode&Limit=10&Fields=RunTimeTicks`);
if (!r.Items.length) return console.log('No results'); if (!r.Items || !r.Items.length) return console.log('No results');
console.log('TYPE\tTITLE\tNAME\tYEAR'); console.log('TYPE\tTITLE\tNAME\tYEAR');
r.Items.forEach(i => { r.Items.forEach(i => {
console.log([i.Type, i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t')); console.log([i.Type, i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t'));
@ -121,7 +220,7 @@ const commands = {
}, },
async libraries() { async libraries() {
const uid = getUserId(); const uid = await getUserId();
const r = await api(`/Users/${uid}/Views`); const r = await api(`/Users/${uid}/Views`);
console.log('NAME\tTYPE\tID'); console.log('NAME\tTYPE\tID');
(r.Items || []).forEach(l => { (r.Items || []).forEach(l => {