@docsworth/cli 0.0.0-stage → 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/LICENSE +55 -0
- package/README.md +186 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +409 -0
- package/dist/core/acl.d.ts +28 -0
- package/dist/core/acl.js +102 -0
- package/dist/core/audit-log.d.ts +133 -0
- package/dist/core/audit-log.js +290 -0
- package/dist/core/blocks.d.ts +35 -0
- package/dist/core/blocks.js +417 -0
- package/dist/core/codeowners.d.ts +62 -0
- package/dist/core/codeowners.js +133 -0
- package/dist/core/config.d.ts +96 -0
- package/dist/core/config.js +131 -0
- package/dist/core/coverage.d.ts +25 -0
- package/dist/core/coverage.js +41 -0
- package/dist/core/duration.d.ts +20 -0
- package/dist/core/duration.js +60 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/frontmatter.d.ts +17 -0
- package/dist/core/frontmatter.js +40 -0
- package/dist/core/gate.d.ts +17 -0
- package/dist/core/gate.js +25 -0
- package/dist/core/github.d.ts +152 -0
- package/dist/core/github.js +234 -0
- package/dist/core/host.d.ts +125 -0
- package/dist/core/host.js +22 -0
- package/dist/core/identity.d.ts +39 -0
- package/dist/core/identity.js +127 -0
- package/dist/core/init.d.ts +25 -0
- package/dist/core/init.js +86 -0
- package/dist/core/lint.d.ts +17 -0
- package/dist/core/lint.js +115 -0
- package/dist/core/mcp/audit-transport.d.ts +16 -0
- package/dist/core/mcp/audit-transport.js +238 -0
- package/dist/core/mcp/server.d.ts +19 -0
- package/dist/core/mcp/server.js +207 -0
- package/dist/core/mcp/text.d.ts +14 -0
- package/dist/core/mcp/text.js +43 -0
- package/dist/core/mirror-view.d.ts +68 -0
- package/dist/core/mirror-view.js +367 -0
- package/dist/core/new-page.d.ts +52 -0
- package/dist/core/new-page.js +49 -0
- package/dist/core/policy.d.ts +10 -0
- package/dist/core/policy.js +30 -0
- package/dist/core/polish.d.ts +42 -0
- package/dist/core/polish.js +1000 -0
- package/dist/core/references.d.ts +52 -0
- package/dist/core/references.js +1242 -0
- package/dist/core/repo.d.ts +42 -0
- package/dist/core/repo.js +116 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +49 -0
- package/dist/core/search.d.ts +24 -0
- package/dist/core/search.js +98 -0
- package/dist/core/serve/auth.d.ts +23 -0
- package/dist/core/serve/auth.js +38 -0
- package/dist/core/serve/server.d.ts +22 -0
- package/dist/core/serve/server.js +138 -0
- package/dist/core/serve/sessions.d.ts +39 -0
- package/dist/core/serve/sessions.js +130 -0
- package/dist/core/serve/token.d.ts +10 -0
- package/dist/core/serve/token.js +96 -0
- package/dist/core/service.d.ts +375 -0
- package/dist/core/service.js +815 -0
- package/dist/core/sla.d.ts +16 -0
- package/dist/core/sla.js +20 -0
- package/dist/core/stale.d.ts +16 -0
- package/dist/core/stale.js +23 -0
- package/dist/core/tool-registry.d.ts +31 -0
- package/dist/core/tool-registry.js +77 -0
- package/dist/core/url.d.ts +4 -0
- package/dist/core/url.js +31 -0
- package/dist/core/version.d.ts +22 -0
- package/dist/core/version.js +32 -0
- package/dist/core/writes.d.ts +58 -0
- package/dist/core/writes.js +113 -0
- package/dist/web/assets/index-BglLMIyN.css +1 -0
- package/dist/web/assets/index-DugFPoRu.js +25 -0
- package/dist/web/index.html +14 -0
- package/docs/format-spec.md +311 -0
- package/package.json +75 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/bin/docsworth-mcp.mjs +46 -0
- package/plugin/skills/draft-runbook/SKILL.md +75 -0
- package/plugin/skills/polish-page/SKILL.md +64 -0
- package/plugin/skills/update-after-incident/SKILL.md +59 -0
- package/plugin/skills/verify-page/SKILL.md +46 -0
- package/templates/github/knowledge.yml +51 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Identity } from "./acl.js";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
import { UserError } from "./errors.js";
|
|
4
|
+
export type AuditKind = "tool_call" | "tool_intent" | "server_start" | "server_stop" | "auth_failure" | "mirror_run" | "mirror_purge" | "promote";
|
|
5
|
+
export interface AuditActor {
|
|
6
|
+
login: string | null;
|
|
7
|
+
mode: Identity["mode"];
|
|
8
|
+
teams: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface AuditEvent {
|
|
11
|
+
v: 1;
|
|
12
|
+
ts: string;
|
|
13
|
+
/** Increases by one per event within a session; a gap means a line was removed. */
|
|
14
|
+
seq: number;
|
|
15
|
+
kind: AuditKind;
|
|
16
|
+
session: string;
|
|
17
|
+
server: {
|
|
18
|
+
version: string;
|
|
19
|
+
repo: string | null;
|
|
20
|
+
commit: string | null;
|
|
21
|
+
};
|
|
22
|
+
/** The knowledge actually served for this event (HEAD, local changes, content hash). */
|
|
23
|
+
snapshot?: {
|
|
24
|
+
commit: string | null;
|
|
25
|
+
dirty: boolean | null;
|
|
26
|
+
content_sha256: string;
|
|
27
|
+
};
|
|
28
|
+
actor?: AuditActor;
|
|
29
|
+
client?: {
|
|
30
|
+
name: string;
|
|
31
|
+
version: string;
|
|
32
|
+
};
|
|
33
|
+
tool?: string;
|
|
34
|
+
/** Allowlisted, non-content arguments only (see mcp/audit-transport.ts). */
|
|
35
|
+
args?: Record<string, string | number | boolean>;
|
|
36
|
+
outcome?: "ok" | "denied" | "error";
|
|
37
|
+
code?: string;
|
|
38
|
+
/** Ids from the final, ACL-filtered response only. */
|
|
39
|
+
returned?: string[];
|
|
40
|
+
excluded?: string[];
|
|
41
|
+
pr_url?: string;
|
|
42
|
+
detail?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
export type AuditInput = Omit<AuditEvent, "v" | "ts" | "seq" | "session" | "server">;
|
|
45
|
+
export interface AuditSink {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** A required sink that cannot take an event fails the audited operation (fail closed). */
|
|
48
|
+
readonly required: boolean;
|
|
49
|
+
/** Events allowed to wait for this sink. Past it, new events are refused (required) or dropped. */
|
|
50
|
+
readonly maxQueue?: number;
|
|
51
|
+
write(event: AuditEvent, line: string): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export declare class AuditUnavailable extends UserError {
|
|
54
|
+
/** The event that a required sink couldn't take (other sinks may have it). */
|
|
55
|
+
seq?: number;
|
|
56
|
+
/** Required sinks that did record it, and optional sinks it was queued on: the ones a correction must reach. */
|
|
57
|
+
accepted?: {
|
|
58
|
+
required: AuditSink[];
|
|
59
|
+
optional: AuditSink[];
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export declare function looksSecret(s: string): boolean;
|
|
63
|
+
export declare const redactSecret: (s: string) => string;
|
|
64
|
+
export declare function actorOf(who: Identity): AuditActor;
|
|
65
|
+
export declare class AuditLog {
|
|
66
|
+
private readonly sinks;
|
|
67
|
+
readonly base: {
|
|
68
|
+
session: string;
|
|
69
|
+
server: AuditEvent["server"];
|
|
70
|
+
};
|
|
71
|
+
private readonly clock;
|
|
72
|
+
private readonly log;
|
|
73
|
+
private seq;
|
|
74
|
+
private readonly chains;
|
|
75
|
+
/** Events queued per sink and not yet written: the bound a slow collector can't exceed. */
|
|
76
|
+
private readonly depth;
|
|
77
|
+
private readonly warned;
|
|
78
|
+
constructor(sinks: AuditSink[], base: {
|
|
79
|
+
session: string;
|
|
80
|
+
server: AuditEvent["server"];
|
|
81
|
+
}, clock?: () => Date, log?: (msg: string) => void);
|
|
82
|
+
/**
|
|
83
|
+
* Queue an event on every sink, in `seq` order per sink. Resolves with its `seq` once
|
|
84
|
+
* every required sink has it; rejects with AuditUnavailable (carrying the `seq`) if one
|
|
85
|
+
* cannot take it. Optional sinks are written in the background and only warn (once per
|
|
86
|
+
* sink) on failure.
|
|
87
|
+
*/
|
|
88
|
+
emit(input: AuditInput, opts?: {
|
|
89
|
+
only?: {
|
|
90
|
+
required: AuditSink[];
|
|
91
|
+
optional: AuditSink[];
|
|
92
|
+
};
|
|
93
|
+
}): Promise<number>;
|
|
94
|
+
/** Wait for every queued write, required or not (shutdown, tests). */
|
|
95
|
+
flush(): Promise<void>;
|
|
96
|
+
private warnOnce;
|
|
97
|
+
}
|
|
98
|
+
/** `$XDG_STATE_HOME/docsworth/<repo>-<hash>/audit`, outside the checkout so it can't be committed. */
|
|
99
|
+
/**
|
|
100
|
+
* This user's state for this checkout: `$XDG_STATE_HOME/docsworth/<repo>-<hash>`. Outside
|
|
101
|
+
* the repo on purpose: nothing in it can be committed, or forged by a checkout.
|
|
102
|
+
*/
|
|
103
|
+
export declare function stateDir(repoRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
104
|
+
export declare function defaultAuditDir(repoRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
105
|
+
/** writeSync may write fewer bytes than asked; loop until every byte is down. */
|
|
106
|
+
export declare function writeAll(fd: number, text: string, write?: (fd: number, buf: Buffer, off: number, len: number) => number): void;
|
|
107
|
+
/** Marks a directory as Docsworth's audit directory, so only it is chmodded and pruned. */
|
|
108
|
+
export declare const AUDIT_MARKER = ".docsworth-audit";
|
|
109
|
+
/**
|
|
110
|
+
* Daily JSON-lines files (`audit-YYYY-MM-DD.jsonl`), directory 0700 and files 0600. A
|
|
111
|
+
* symlinked directory or file is refused, so the log can't be redirected elsewhere.
|
|
112
|
+
*/
|
|
113
|
+
export declare class FileSink implements AuditSink {
|
|
114
|
+
readonly dir: string;
|
|
115
|
+
readonly required: boolean;
|
|
116
|
+
readonly name = "file";
|
|
117
|
+
constructor(dir: string, required: boolean, retentionDays: number, now: Date);
|
|
118
|
+
write(event: AuditEvent, line: string): Promise<void>;
|
|
119
|
+
/** Delete daily files older than the retention window. */
|
|
120
|
+
private prune;
|
|
121
|
+
}
|
|
122
|
+
export declare function resolveAuditDir(repoRoot: string, configured: string | undefined, env?: NodeJS.ProcessEnv): string;
|
|
123
|
+
/** The knowledge repo's HEAD commit, if it is a git checkout. */
|
|
124
|
+
export declare function headCommit(repoRoot: string): string | null;
|
|
125
|
+
/**
|
|
126
|
+
* Build the core audit log from config: the file sink when `audit_log.enabled`, plus any
|
|
127
|
+
* sinks the caller adds (the pro HTTP sink). Returns null when there is nothing to write to.
|
|
128
|
+
*/
|
|
129
|
+
export declare function createAuditLog(repoRoot: string, config: Config, extraSinks?: AuditSink[], opts?: {
|
|
130
|
+
now?: () => Date;
|
|
131
|
+
env?: NodeJS.ProcessEnv;
|
|
132
|
+
log?: (msg: string) => void;
|
|
133
|
+
}): AuditLog | null;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, realpathSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import { homedir, userInfo } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { UserError } from "./errors.js";
|
|
7
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
8
|
+
export class AuditUnavailable extends UserError {
|
|
9
|
+
/** The event that a required sink couldn't take (other sinks may have it). */
|
|
10
|
+
seq;
|
|
11
|
+
/** Required sinks that did record it, and optional sinks it was queued on: the ones a correction must reach. */
|
|
12
|
+
accepted;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The person behind a call. GitHub mode: their login. Local mode has no GitHub identity,
|
|
16
|
+
* so the OS user who runs the server is named instead (mode `local` says which it is).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* A value can pass its shape check and still be a credential (a token pasted as a path).
|
|
20
|
+
* Known token formats, and long high-entropy runs (mixed case plus digits), become
|
|
21
|
+
* "<redacted>". This is a heuristic: it catches the common shapes, not every secret.
|
|
22
|
+
*/
|
|
23
|
+
const TOKEN_PREFIX = /(gh[pousr]_[A-Za-z0-9]|github_pat_|glpat-|xox[abprs]-|\bsk-[A-Za-z0-9]|AKIA[0-9A-Z]{12}|AIza[0-9A-Za-z_-]{10}|eyJ[A-Za-z0-9_-]{8,}\.)/;
|
|
24
|
+
const HIGH_ENTROPY = /[A-Za-z0-9+/_=-]{24,}/g;
|
|
25
|
+
export function looksSecret(s) {
|
|
26
|
+
if (TOKEN_PREFIX.test(s))
|
|
27
|
+
return true;
|
|
28
|
+
return (s.match(HIGH_ENTROPY) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
|
|
29
|
+
}
|
|
30
|
+
export const redactSecret = (s) => (looksSecret(s) ? "<redacted>" : s);
|
|
31
|
+
/** Every caller-influenced string in an event, redacted if it looks like a secret. Applied in emit, so no producer can skip it. */
|
|
32
|
+
function scrub(input) {
|
|
33
|
+
const deep = (v) => typeof v === "string" ? redactSecret(v) : Array.isArray(v) ? v.map(deep) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deep(x)])) : v;
|
|
34
|
+
return deep(input);
|
|
35
|
+
}
|
|
36
|
+
export function actorOf(who) {
|
|
37
|
+
return { login: who.login ?? localUser(), mode: who.mode, teams: [...who.teams].sort() };
|
|
38
|
+
}
|
|
39
|
+
function localUser() {
|
|
40
|
+
try {
|
|
41
|
+
return userInfo().username || null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null; // no passwd entry (some containers); recorded as unknown
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class AuditLog {
|
|
48
|
+
sinks;
|
|
49
|
+
base;
|
|
50
|
+
clock;
|
|
51
|
+
log;
|
|
52
|
+
seq = 0;
|
|
53
|
+
chains = new Map();
|
|
54
|
+
/** Events queued per sink and not yet written: the bound a slow collector can't exceed. */
|
|
55
|
+
depth = new Map();
|
|
56
|
+
warned = new Set();
|
|
57
|
+
constructor(sinks, base, clock = () => new Date(), log = (m) => process.stderr.write(`docsworth: ${m}\n`)) {
|
|
58
|
+
this.sinks = sinks;
|
|
59
|
+
this.base = base;
|
|
60
|
+
this.clock = clock;
|
|
61
|
+
this.log = log;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Queue an event on every sink, in `seq` order per sink. Resolves with its `seq` once
|
|
65
|
+
* every required sink has it; rejects with AuditUnavailable (carrying the `seq`) if one
|
|
66
|
+
* cannot take it. Optional sinks are written in the background and only warn (once per
|
|
67
|
+
* sink) on failure.
|
|
68
|
+
*/
|
|
69
|
+
emit(input, opts = {}) {
|
|
70
|
+
const event = { v: 1, ts: this.clock().toISOString(), seq: ++this.seq, session: this.base.session, server: this.base.server, ...scrub(input) };
|
|
71
|
+
const line = JSON.stringify(event);
|
|
72
|
+
// A correction (`only`) goes just to the sinks that took the event it corrects. To a
|
|
73
|
+
// required sink that already wrote that event it isn't bounded by the queue: there's at
|
|
74
|
+
// most one per write it follows, so it can't grow without limit. Optional sinks keep the bound.
|
|
75
|
+
const targets = opts.only ? [...opts.only.required, ...opts.only.optional] : this.sinks;
|
|
76
|
+
const unbounded = new Set(opts.only?.required ?? []);
|
|
77
|
+
const required = [];
|
|
78
|
+
const optional = [];
|
|
79
|
+
for (const sink of targets) {
|
|
80
|
+
const queued = this.depth.get(sink) ?? 0;
|
|
81
|
+
if (!unbounded.has(sink) && sink.maxQueue !== undefined && queued >= sink.maxQueue) {
|
|
82
|
+
const full = new Error(`queue is full (${sink.maxQueue} events waiting)`);
|
|
83
|
+
if (sink.required)
|
|
84
|
+
required.push({ sink, done: Promise.reject(full) });
|
|
85
|
+
else
|
|
86
|
+
this.warnOnce(sink.name, full);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
this.depth.set(sink, queued + 1);
|
|
90
|
+
const write = (this.chains.get(sink) ?? Promise.resolve())
|
|
91
|
+
.then(() => sink.write(event, line))
|
|
92
|
+
.finally(() => this.depth.set(sink, (this.depth.get(sink) ?? 1) - 1));
|
|
93
|
+
// A failed write must not poison the events queued after it.
|
|
94
|
+
this.chains.set(sink, write.catch(() => { }));
|
|
95
|
+
if (sink.required)
|
|
96
|
+
required.push({ sink, done: write });
|
|
97
|
+
else {
|
|
98
|
+
optional.push(sink);
|
|
99
|
+
write.catch((err) => this.warnOnce(sink.name, err));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Wait for every required sink, not just the first failure, so the error can say which
|
|
103
|
+
// ones did record the event.
|
|
104
|
+
return Promise.allSettled(required.map((r) => r.done)).then((results) => {
|
|
105
|
+
const failed = results.findIndex((r) => r.status === "rejected");
|
|
106
|
+
if (failed === -1)
|
|
107
|
+
return event.seq;
|
|
108
|
+
const reason = results[failed].reason;
|
|
109
|
+
const err = new AuditUnavailable(`the audit log (${required[failed].sink.name}) could not record this call: ${reason.message}`);
|
|
110
|
+
err.seq = event.seq;
|
|
111
|
+
err.accepted = { required: required.filter((_, i) => results[i].status === "fulfilled").map((r) => r.sink), optional };
|
|
112
|
+
throw err;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Wait for every queued write, required or not (shutdown, tests). */
|
|
116
|
+
async flush() {
|
|
117
|
+
await Promise.all(this.chains.values());
|
|
118
|
+
}
|
|
119
|
+
warnOnce(name, err) {
|
|
120
|
+
if (this.warned.has(name))
|
|
121
|
+
return;
|
|
122
|
+
this.warned.add(name);
|
|
123
|
+
this.log(`audit sink ${name} is failing (${err.message}); events to it are being dropped`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** `$XDG_STATE_HOME/docsworth/<repo>-<hash>/audit`, outside the checkout so it can't be committed. */
|
|
127
|
+
/**
|
|
128
|
+
* This user's state for this checkout: `$XDG_STATE_HOME/docsworth/<repo>-<hash>`. Outside
|
|
129
|
+
* the repo on purpose: nothing in it can be committed, or forged by a checkout.
|
|
130
|
+
*/
|
|
131
|
+
export function stateDir(repoRoot, env = process.env) {
|
|
132
|
+
const state = env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
133
|
+
const abs = resolve(repoRoot);
|
|
134
|
+
return join(state, "docsworth", `${basename(abs)}-${createHash("sha256").update(abs).digest("hex").slice(0, 8)}`);
|
|
135
|
+
}
|
|
136
|
+
export function defaultAuditDir(repoRoot, env = process.env) {
|
|
137
|
+
return join(stateDir(repoRoot, env), "audit");
|
|
138
|
+
}
|
|
139
|
+
const DAY_FILE = /^audit-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
|
140
|
+
/** writeSync may write fewer bytes than asked; loop until every byte is down. */
|
|
141
|
+
export function writeAll(fd, text, write = writeSync) {
|
|
142
|
+
const buf = Buffer.from(text, "utf8");
|
|
143
|
+
let off = 0;
|
|
144
|
+
while (off < buf.length) {
|
|
145
|
+
const n = write(fd, buf, off, buf.length - off);
|
|
146
|
+
if (n <= 0)
|
|
147
|
+
throw new Error("write made no progress");
|
|
148
|
+
off += n;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Marks a directory as Docsworth's audit directory, so only it is chmodded and pruned. */
|
|
152
|
+
export const AUDIT_MARKER = ".docsworth-audit";
|
|
153
|
+
function lstatExists(p) {
|
|
154
|
+
try {
|
|
155
|
+
lstatSync(p);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Daily JSON-lines files (`audit-YYYY-MM-DD.jsonl`), directory 0700 and files 0600. A
|
|
164
|
+
* symlinked directory or file is refused, so the log can't be redirected elsewhere.
|
|
165
|
+
*/
|
|
166
|
+
export class FileSink {
|
|
167
|
+
dir;
|
|
168
|
+
required;
|
|
169
|
+
name = "file";
|
|
170
|
+
constructor(dir, required, retentionDays, now) {
|
|
171
|
+
this.dir = dir;
|
|
172
|
+
this.required = required;
|
|
173
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
174
|
+
const st = lstatSync(dir);
|
|
175
|
+
if (st.isSymbolicLink())
|
|
176
|
+
throw new AuditUnavailable(`audit directory ${dir} is a symlink; refusing to write through it`);
|
|
177
|
+
if (!st.isDirectory())
|
|
178
|
+
throw new AuditUnavailable(`audit directory ${dir} is not a directory`);
|
|
179
|
+
// Only a directory of its own is ever chmodded or pruned: new or empty, holding only
|
|
180
|
+
// audit files, or already marked. Anything else (say $HOME) is refused untouched.
|
|
181
|
+
const marker = join(dir, AUDIT_MARKER);
|
|
182
|
+
if (lstatExists(marker)) {
|
|
183
|
+
if (!lstatSync(marker).isFile())
|
|
184
|
+
throw new AuditUnavailable(`${marker} is a symlink or directory, not the marker Docsworth writes`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const foreign = readdirSync(dir).filter((e) => !DAY_FILE.test(e));
|
|
188
|
+
if (foreign.length)
|
|
189
|
+
throw new AuditUnavailable(`audit_log.dir ${dir} holds ${foreign.slice(0, 3).join(", ")}${foreign.length > 3 ? ", …" : ""} and isn't a Docsworth audit directory (no ${AUDIT_MARKER}); use a directory of its own`);
|
|
190
|
+
const fd = openSync(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
191
|
+
closeSync(fd);
|
|
192
|
+
}
|
|
193
|
+
// `mode` only applies to directories mkdir creates; tighten one that already existed.
|
|
194
|
+
if ((st.mode & 0o077) !== 0)
|
|
195
|
+
chmodSync(dir, 0o700);
|
|
196
|
+
this.prune(retentionDays, now);
|
|
197
|
+
}
|
|
198
|
+
async write(event, line) {
|
|
199
|
+
const file = join(this.dir, `audit-${event.ts.slice(0, 10)}.jsonl`);
|
|
200
|
+
// O_NOFOLLOW: a symlink planted at the file's path makes the open fail.
|
|
201
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
202
|
+
try {
|
|
203
|
+
// The open mode only applies to a file it creates; tighten one that already existed.
|
|
204
|
+
if ((fstatSync(fd).mode & 0o077) !== 0)
|
|
205
|
+
fchmodSync(fd, 0o600);
|
|
206
|
+
writeAll(fd, `${line}\n`);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Delete daily files older than the retention window. */
|
|
213
|
+
prune(days, now) {
|
|
214
|
+
const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
|
|
215
|
+
for (const name of readdirSync(this.dir)) {
|
|
216
|
+
const m = DAY_FILE.exec(name);
|
|
217
|
+
if (m && m[1] < cutoff)
|
|
218
|
+
unlinkSync(join(this.dir, name));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** The configured audit directory, refusing one inside the checkout unless git ignores it. */
|
|
223
|
+
/** A path's canonical form, even before it exists: its nearest existing ancestor resolved through symlinks, plus the rest. */
|
|
224
|
+
function canonical(p) {
|
|
225
|
+
const abs = resolve(p);
|
|
226
|
+
if (existsSync(abs))
|
|
227
|
+
return realpathSync(abs);
|
|
228
|
+
const parent = dirname(abs);
|
|
229
|
+
return parent === abs ? abs : join(canonical(parent), basename(abs));
|
|
230
|
+
}
|
|
231
|
+
export function resolveAuditDir(repoRoot, configured, env = process.env) {
|
|
232
|
+
const dir = !configured ? defaultAuditDir(repoRoot, env) : isAbsolute(configured) ? configured : resolve(repoRoot, configured);
|
|
233
|
+
// Canonical on both sides: a path that looks outside the checkout but runs through a
|
|
234
|
+
// symlink into it is inside, and must pass the same git-ignore check.
|
|
235
|
+
const rel = relative(canonical(repoRoot), canonical(dir));
|
|
236
|
+
// Inside the checkout (the root itself included): one `git add -A` away from being
|
|
237
|
+
// committed, unless ignored. This covers a default under an XDG_STATE_HOME in the repo.
|
|
238
|
+
const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); // `..audit` is inside
|
|
239
|
+
if (!outside) {
|
|
240
|
+
const label = configured ?? dir;
|
|
241
|
+
// Ask about a file the sink would write, which works before the directory exists.
|
|
242
|
+
const r = spawnSync("git", ["-C", repoRoot, "check-ignore", "-q", join(rel, "audit-2000-01-01.jsonl")], { encoding: "utf8" });
|
|
243
|
+
if (r.status === 0) {
|
|
244
|
+
// Ignoring doesn't untrack: files already committed there would keep being changed
|
|
245
|
+
// and committed. Refuse a directory that holds any tracked file.
|
|
246
|
+
const tracked = spawnSync("git", ["-C", repoRoot, "ls-files", "--", rel || "."], { encoding: "utf8" });
|
|
247
|
+
if (tracked.status !== 0 || tracked.stdout.trim())
|
|
248
|
+
throw new AuditUnavailable(`audit_log.dir ${label} is ignored by git but already holds tracked files (${tracked.stdout.trim().split("\n")[0] || "git couldn't say"}); untrack them with \`git rm --cached\` or use a path outside the checkout`);
|
|
249
|
+
return dir;
|
|
250
|
+
}
|
|
251
|
+
if (r.status === 1)
|
|
252
|
+
throw new AuditUnavailable(`audit_log.dir ${label} is inside the repository and not ignored by git; add it to .gitignore or use a path outside the checkout`);
|
|
253
|
+
// git couldn't answer. Allow only when git positively says this isn't a repository;
|
|
254
|
+
// a missing git, a "dubious ownership" refusal or anything else is refused.
|
|
255
|
+
const tree = spawnSync("git", ["-C", repoRoot, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8" });
|
|
256
|
+
const notARepo = !tree.error && tree.status === 128 && /not a git repository/i.test(tree.stderr);
|
|
257
|
+
if (!notARepo)
|
|
258
|
+
throw new AuditUnavailable(`couldn't confirm that audit_log.dir ${label} is ignored by git (${(r.error?.message ?? r.stderr ?? "no answer").trim() || "no answer"}); use a path outside the checkout`);
|
|
259
|
+
}
|
|
260
|
+
return dir;
|
|
261
|
+
}
|
|
262
|
+
/** The knowledge repo's HEAD commit, if it is a git checkout. */
|
|
263
|
+
export function headCommit(repoRoot) {
|
|
264
|
+
const r = spawnSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
265
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Build the core audit log from config: the file sink when `audit_log.enabled`, plus any
|
|
269
|
+
* sinks the caller adds (the pro HTTP sink). Returns null when there is nothing to write to.
|
|
270
|
+
*/
|
|
271
|
+
export function createAuditLog(repoRoot, config, extraSinks = [], opts = {}) {
|
|
272
|
+
const now = opts.now ?? (() => new Date());
|
|
273
|
+
const log = opts.log ?? ((m) => process.stderr.write(`docsworth: ${m}\n`));
|
|
274
|
+
const sinks = [...extraSinks];
|
|
275
|
+
if (config.audit_log.enabled) {
|
|
276
|
+
try {
|
|
277
|
+
const dir = resolveAuditDir(repoRoot, config.audit_log.dir, opts.env);
|
|
278
|
+
sinks.unshift(new FileSink(dir, config.audit_log.required, config.audit_log.retention_days, now()));
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
// Required: refuse to run unaudited. Optional (best effort): say so, and go on.
|
|
282
|
+
if (config.audit_log.required)
|
|
283
|
+
throw err;
|
|
284
|
+
log(`audit log disabled for this run: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (sinks.length === 0)
|
|
288
|
+
return null;
|
|
289
|
+
return new AuditLog(sinks, { session: randomUUID(), server: { version: PACKAGE_VERSION, repo: config.github.repo ?? null, commit: headCommit(repoRoot) } }, now);
|
|
290
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block structure the polish guard needs: which lines are code, in what blocks, as
|
|
3
|
+
* CommonMark reads them. It follows the spec's parsing strategy for containers (block
|
|
4
|
+
* quotes and list items, matched line by line, with lazy paragraph continuation) and
|
|
5
|
+
* for the two code leaves (fenced and indented). Other leaves only matter here for
|
|
6
|
+
* whether a paragraph is open: a paragraph can't be interrupted by indented code, and
|
|
7
|
+
* only by a list item that has text and, if ordered, starts at 1.
|
|
8
|
+
*
|
|
9
|
+
* Tabs count to the next multiple of four columns, and a tab a container consumes in
|
|
10
|
+
* part leaves the rest as spaces, as CommonMark specifies.
|
|
11
|
+
*
|
|
12
|
+
* Raw HTML blocks aren't modelled: the guard refuses a page that has raw HTML.
|
|
13
|
+
*/
|
|
14
|
+
/** Left in the prose where a code block was. Not a letter, digit or markup. */
|
|
15
|
+
export declare const FENCE_MARK = "\u0002";
|
|
16
|
+
/**
|
|
17
|
+
* Code blocks (fenced and indented) and the prose around them. Each block is its literal,
|
|
18
|
+
* with its info string first when it has one; the prose keeps every other line as
|
|
19
|
+
* written and a FENCE_MARK line where each block began.
|
|
20
|
+
*/
|
|
21
|
+
export type Blocks = {
|
|
22
|
+
fences: string[];
|
|
23
|
+
prose: string;
|
|
24
|
+
/** Per prose line: characters of list markers (and the containers among them) that opened an item on it. */
|
|
25
|
+
markers: number[];
|
|
26
|
+
/** Per prose line: characters of container markers before its content. */
|
|
27
|
+
content: number[];
|
|
28
|
+
/** Per prose line: whether it's a paragraph's (first, continuing or lazy). */
|
|
29
|
+
para: boolean[];
|
|
30
|
+
/** Per prose line: whether it starts a paragraph (where a reference definition may begin). */
|
|
31
|
+
paraStart: boolean[];
|
|
32
|
+
/** Per prose line: whether it starts a list item's first paragraph (where a task box sits). */
|
|
33
|
+
itemPara: boolean[];
|
|
34
|
+
};
|
|
35
|
+
export declare function codeBlocks(body: string): Blocks;
|