Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ This tool is used in security-sensitive environments. Everything it reads is hos

- **Untrusted input everywhere it prints.** Filenames, file contents, frontmatter values, and CLI args pass through `terminalSafe` before stdout/stderr; link targets through `safeHref`. New output paths must do the same — terminal/CI-log injection is a real risk class here.
- **Read-only and offline.** Never write into a scanned bundle, never make network calls, never interpolate input into shell commands (`execFile` + argv arrays only).
- **No prototype-chain reads.** Read option bags through `own()` (`src/security.ts`); frontmatter objects are prototype-less. Never read `options.x` directly.
- **Bounded resources.** `MAX_DOC_BYTES` caps file size; check size before reading, not after. New code that reads files or follows links must respect the same limits.
- **Fail closed.** Unreadable paths, oversized files, and unparseable input produce diagnostics or clean exits — never crashes, never silent acceptance.

## Conventions

- **Spec-first.** `okf/v02-SPEC.md` decides what is an error vs a warning: `validate` errors are spec MUSTs (§11); warnings are SHOULDs and tolerated issues. Do not invent requirements, and do not reject bundles for things §11 says consumers must tolerate (broken links, missing `index.md`, unknown keys).
- **Scoped validation never hides errors.** `validate --git` scans the whole bundle and reports every error; only warnings are scoped. `orphan`, `recommended-index` and `okf-version-*` stay bundle-wide, and `index.md`/`log.md` links count toward scope. If scoping fails, validate everything.
- **Reserved files aren't dependencies.** Links out of `index.md`/`log.md` are excluded from dependents/affected via `isReservedFile` — filename-based, no edge type field.

## Boundaries
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Frontmatter parsing no longer lets the `yaml` library print warnings to stderr. For an unknown tag (e.g. `x: !foo ...`) it echoed the raw source line, so control characters in a document reached the terminal or CI log unsanitized.
- Errors that escape a command (filesystem errors naming a hostile path, git's echo of a CLI argument) now pass through `terminalSafe` before being printed.
- `validate` no longer follows symlinks out of the bundle when checking that linked files and `resource`-style paths exist. A link through a symlink to an outside directory now reads as missing whether or not the target exists, so a hostile bundle can't probe the filesystem. Symlinks that stay inside the bundle still resolve.
- `--git` now runs git with `core.fsmonitor` disabled (and `--no-ext-diff`). A repository's own config could otherwise make `affected --git` / `validate --git` execute an arbitrary command.
- `--md` output escapes `&`, `<` and `>` in document titles, so a title can't inject raw HTML into the generated markdown.
- Options (`ignore`, `scope`, `exclude`, `extraPaths`, `baseUrl`, `includeNav`, ...) and frontmatter fields are no longer read through the prototype chain. A polluted `Object.prototype` elsewhere in a host process could previously hide validation errors (`type`), widen or narrow scope, or redirect Mermaid click links to another origin (`baseUrl`) for library users.
- Reading a bundle now opens at most 32 files at once instead of all of them at once, avoiding file-descriptor exhaustion on large bundles.

### Added

- `validate --git <base>` — opt-in scoping for PR checks. The whole bundle is still scanned and every error is reported; warnings are limited to documents changed or deleted since `<base>` and their dependents (`orphan`, `recommended-index` and `okf-version-*` are always kept). Links from `index.md`/`log.md` count when computing dependents. Prints a `Scoped to N of M docs` line on stderr, fails on git errors, and falls back to validating the whole bundle if scoping fails (e.g. an oversized file). Library: `validate(root, { scope })`, and `ValidateResult` gains `docCount` and `scopedCount`.

### Changed

- The error for `--git` on unsupported commands now reads "only supported by the affected and validate commands".

## [0.8.0] - 2026-10-08

### Added
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ npm i -g @fcmam5/okph
pnpm i -g @fcmam5/okph
```

Or run it without installing — e.g. in a CI job to validate a bundle:

```bash
npx @fcmam5/okph validate ./docs --git origin/main
```

## CLI

```bash
Expand All @@ -32,11 +38,14 @@ okph affected --git main --root docs --md # same, as a markdown link list
okph affected --git main --root docs --exclude '**/{index,log}.md' # skip files entirely
okph validate ./docs # check the bundle against the OKF spec
okph validate ./docs --strict # also fail on warnings (CI gate)
okph validate ./docs --git main # all errors, but warnings only for docs changed since main
okph validate ./docs --strict --ignore prefer-absolute-links # keep relative links, skip that warning
```

`validate` checks a bundle against the OKF spec and prints findings as `path -> target [level] kind: message`, sorted by path. Errors are spec MUSTs (missing or unparseable frontmatter, missing `type`, `index.md`/`log.md` misuse); warnings are spec SHOULDs and tolerated problems (broken links, missing files named by `resource`/`computation`/`executor`/`attester`, relative links, orphans, malformed `status`/`tags`/`generated`/`verified`, missing `description`). Exits `1` on any error — warnings pass unless `--strict` is set.

`validate --git <base>` is an opt-in speed-up for PR checks. It still scans the whole bundle and reports **every error**; only *warnings* are limited to markdown files changed or deleted since `<base>` and their dependents (links from `index.md`/`log.md` count here, so a broken listing entry is still reported). `orphan`, `recommended-index` and `okf-version-*` warnings are always kept, because a changed link can orphan a document that never changed. A summary line on stderr says how many docs were in scope. Git failures (unknown `<base>`, not a repository) exit non-zero rather than passing. It is not a substitute for a full `validate` run on your main branch.

`--ignore <kind>` suppresses a warning kind (repeatable); ignored findings are neither printed nor counted, so `--strict` skips them. Errors can't be ignored, and unknown kinds are rejected. Kinds: `escapes-bundle`, `invalid-generated`, `invalid-status`, `invalid-tags`, `invalid-verified`, `log-date-order`, `missing-doc`, `missing-file`, `missing-resource`, `missing-source-resource`, `okf-version-format`, `okf-version-unsupported`, `orphan`, `prefer-absolute-links`, `recommended-description`, `recommended-index`. The kind is the word after `[warning]` in the output. Example: if your bundle lives in `docs/` and your links are relative (`../concepts/x.md`) so they stay clickable in your editor, `--ignore prefer-absolute-links` stops the validator from warning about them. A `/`-prefixed link means "from the bundle root", but editors resolve it from the repo root, so it would break there.

`deps`/`dependents`/`affected` scan the knowledge base from the current working directory and print sorted paths, one per line — no Mermaid. Paths are relative to your cwd, so run from the repo root for repo-relative output. `affected` means *potentially* affected: reachable through links, not necessarily impacted. Links are read as citations — `a → b` means "a relies on b's content", so `affected b.md` reports `b.md` plus everything that cites it, transitively. `affected --git <base>` seeds from markdown files changed or deleted since `<base>` (commits, working tree, and untracked files), then reports those files plus their transitive dependents — useful for "what might need review after this branch" checks.
Expand Down
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ When using `okph` in your application:

### Known limitations

- `--git` (on `affected` and `validate`) runs your local `git` against the
scanned repository. Git reads that repository's config, and some settings
can execute commands. `okph` disables `core.fsmonitor` and external diff
drivers, but it cannot enumerate every such setting (for example, clean
filters named in the repo's own `.git/config`). Do not use `--git` on a
checkout you do not trust. `okph` passes arguments as an argv array (no
shell) and rejects a `<base>` starting with `-`.
- `validate --git` hides warnings outside the changed set. Errors are never
scoped, but keep a full `validate` run on your main branch as the
authoritative check.

- Very deeply nested markdown (e.g. thousands of nested blockquotes or list
levels, well under the per-file size limit) overflows the stack in the
`marked` lexer. `okph` reports the error and exits non-zero; it does not
Expand Down
78 changes: 64 additions & 14 deletions bin/okph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ function mdLines(
): string[] {
const labelOf = new Map(graph.nodes.map((n) => [n.path, n.label]));
return paths.map((rel) => {
const label = terminalSafe(labelOf.get(rel) ?? rel).replace(/[[\]]/g, "");
const label = terminalSafe(labelOf.get(rel) ?? rel)
.replace(/[[\]]/g, "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const href = safeHref(display(rel), baseUrl);
if (href === null) return `- ${label}`;
// Angle-wrapped destinations are the CommonMark escape for parens in
Expand All @@ -46,6 +50,24 @@ function mdLines(
});
}

/**
* Graph of `root` plus the changed docs that exist in it as seeds. Deleted
* docs are kept as stub nodes (unless excluded) so edges pointing at them
* survive and their dependents show up as affected.
*/
async function seedGraph(
root: string,
changed: Awaited<ReturnType<typeof changedMarkdownFiles>>,
exclude: string[] = []
): Promise<{ graph: Graph; seeds: string[] }> {
const graph = await loadGraph(root, { extraPaths: changed.deleted, exclude });
const known = new Set(graph.nodes.map((n) => n.path));
const seeds = [...changed.added, ...changed.modified, ...changed.deleted].filter((s) =>
known.has(s)
);
return { graph, seeds };
}

/** Print the affected set as a Mermaid subgraph, markdown list, or sorted path list. */
function emitAffected(
graph: Graph,
Expand Down Expand Up @@ -74,7 +96,7 @@ Usage:
okph dependents <file> [--root <dir>] [--graph] [--md] [--exclude <glob>...]
okph affected <file> [--root <dir>] [--graph] [--md] [--exclude <glob>...]
okph affected --git <base> [--root <dir>] [--graph] [--md] [--exclude <glob>...]
okph validate [path] [--strict] [--ignore <kind>...]
okph validate [path] [--git <base>] [--strict] [--ignore <kind>...]

Options:
--base-url <url> Emit absolute links joined onto <url> (graph clicks and
Expand All @@ -95,6 +117,10 @@ Options:
--git <base> Seed affected from markdown files changed or deleted
since <base> (commits, working tree, and untracked files).
<base> may be any revision or range, e.g. HEAD~1, main..HEAD.
With validate: the whole bundle is still scanned and every
error is reported, but warnings are limited to changed
docs and their dependents (orphans, index and version
warnings are always kept). Not a substitute for a full run.
--strict validate: exit non-zero on warnings too (CI gate).
--ignore <kind> validate: suppress a warning kind (repeatable). Ignored
findings are not printed or counted. Errors can't be ignored.
Expand Down Expand Up @@ -185,8 +211,10 @@ export async function run(argv: string[], cwd: string = process.cwd()): Promise<
return 1;
}
const gitMode = command === "affected" && values.git !== undefined;
if (values.git !== undefined && !gitMode) {
process.stderr.write(`Error: --git is only supported by the affected command.\n\n${USAGE}`);
if (values.git !== undefined && !gitMode && command !== "validate") {
process.stderr.write(
`Error: --git is only supported by the affected and validate commands.\n\n${USAGE}`
);
return 1;
}
if (gitMode && target) {
Expand All @@ -211,17 +239,14 @@ export async function run(argv: string[], cwd: string = process.cwd()): Promise<
const display = (rel: string) => toPosix(path.relative(cwd, path.join(root, rel)));

if (command === "affected" && values.git !== undefined) {
const { added, modified, deleted } = await changedMarkdownFiles(values.git, root);
// Deleted docs are kept as stub nodes (unless excluded) so edges
// pointing at them survive and their dependents show up as affected.
const graph = await loadGraph(root, { extraPaths: deleted, exclude });
const changed = await changedMarkdownFiles(values.git, root);
const { added, modified, deleted } = changed;
const { graph, seeds } = await seedGraph(root, changed, exclude);
if (deleted.length > 0) {
process.stderr.write(
`Deleted since ${terminalSafe(values.git)}: ${deleted.map((p) => terminalSafe(display(p))).join(", ")}\n`
);
}
const known = new Set(graph.nodes.map((n) => n.path));
const seeds = [...added, ...modified, ...deleted].filter((s) => known.has(s));
if (seeds.length === 0) {
process.stderr.write(
`No markdown files changed since ${terminalSafe(values.git)} (or all were excluded).\n`
Expand Down Expand Up @@ -296,13 +321,30 @@ export async function run(argv: string[], cwd: string = process.cwd()): Promise<
process.stderr.write(problem);
return 1;
}
const { diagnostics, version, errorCount, warningCount } = await validate(root, {
ignore: ignore as WarningKind[],
});
let scope: string[] | undefined;
if (values.git !== undefined) {
const changed = await changedMarkdownFiles(values.git, root);
try {
const { graph, seeds } = await seedGraph(root, changed);
// includeNav: a broken link in index.md/log.md is still a finding.
scope = seeds.length > 0 ? getAffected(graph, seeds, { includeNav: true }) : [];
} catch (err) {
process.stderr.write(
`Could not scope to changed docs (${terminalSafe(err instanceof Error ? err.message : String(err))}); validating the whole bundle.\n`
);
}
}
const { diagnostics, version, errorCount, warningCount, docCount, scopedCount } =
await validate(root, { ignore: ignore as WarningKind[], ...(scope && { scope }) });
for (const d of diagnostics) {
const where = d.target === undefined ? d.path : `${d.path} -> ${d.target}`;
process.stdout.write(`${terminalSafe(where)} [${d.level}] ${d.kind}: ${d.message}\n`);
}
if (scopedCount !== undefined) {
process.stderr.write(
`Scoped to ${scopedCount} of ${docCount} docs (warnings only; errors cover the whole bundle).\n`
);
}
if (diagnostics.length === 0) {
process.stderr.write(`OKF ${version}: no problems found.\n`);
} else {
Expand Down Expand Up @@ -348,6 +390,14 @@ async function rootError(
}
}

/**
* The stderr line for an error that escaped `run`. Messages can carry hostile
* paths (fs errors) or git's echo of a CLI argument, so they are sanitized.
*/
export function errorLine(err: unknown): string {
return `Error: ${terminalSafe(err instanceof Error ? err.message : String(err))}\n`;
}

/** True when this file is the process entrypoint (not an import). */
function isMain(): boolean {
const entry = process.argv[1];
Expand All @@ -365,7 +415,7 @@ if (isMain()) {
process.exitCode = code;
})
.catch((err: unknown) => {
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.stderr.write(errorLine(err));
process.exitCode = 1;
});
}
39 changes: 19 additions & 20 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { toPosix } from "./discover.js";

const execFileAsync = promisify(execFile);

/**
* Run git with repository-configured command hooks disabled. Git reads the
* scanned repo's own config, and `core.fsmonitor` names a command it runs on
* `diff`/`ls-files`; that must never be a way to execute code through okph.
*/
const runGit = (args: string[], cwd: string) =>
execFileAsync("git", ["-c", "core.fsmonitor=false", ...args], { cwd });

/** Markdown files changed relative to a git base revision. */
export interface ChangedFiles {
/** Added `.md` files (committed additions and untracked), sorted, POSIX-style, relative to `cwd`. */
Expand Down Expand Up @@ -41,36 +49,27 @@ export async function changedMarkdownFiles(base: string, cwd: string): Promise<C
let deleted: string;
let untracked: string;
try {
const { stdout: top } = await execFileAsync(
"git",
["rev-parse", "--show-toplevel"],
{ cwd: scanRoot }
);
const { stdout: top } = await runGit(["rev-parse", "--show-toplevel"], scanRoot);
repoRoot = await realpath(top.replace(/\r?\n$/, ""));
// --no-renames overrides the caller's diff.renames config: without it, a
// renamed file can surface as status R, which none of the filters below
// match, and the file silently disappears from all three arrays. Forcing
// it off makes a rename resolve as delete (old path) + add (new path),
// consistent with how deleted-doc stubs already work.
const [a, m, del, u] = await Promise.all([
execFileAsync(
"git",
["diff", "--no-renames", "--name-only", "-z", "--diff-filter=A", base, "--"],
{ cwd: repoRoot }
runGit(
["diff", "--no-renames", "--no-ext-diff", "--name-only", "-z", "--diff-filter=A", base, "--"],
repoRoot
),
execFileAsync(
"git",
["diff", "--no-renames", "--name-only", "-z", "--diff-filter=M", base, "--"],
{ cwd: repoRoot }
runGit(
["diff", "--no-renames", "--no-ext-diff", "--name-only", "-z", "--diff-filter=M", base, "--"],
repoRoot
),
execFileAsync(
"git",
["diff", "--no-renames", "--name-only", "-z", "--diff-filter=D", base, "--"],
{ cwd: repoRoot }
runGit(
["diff", "--no-renames", "--no-ext-diff", "--name-only", "-z", "--diff-filter=D", base, "--"],
repoRoot
),
execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
cwd: repoRoot,
}),
runGit(["ls-files", "--others", "--exclude-standard", "-z"], repoRoot),
]);
addedDiff = a.stdout;
modified = m.stdout;
Expand Down
3 changes: 2 additions & 1 deletion src/graph.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { isReservedFile } from "./discover.js";
import type { ParsedLink } from "./parse.js";
import { own } from "./security.js";

/** Input to the graph builder: a parsed doc plus its root-relative path. */
export interface GraphInput {
Expand Down Expand Up @@ -37,7 +38,7 @@ export interface TraversalOptions {

/** Edges eligible for a dependent-side walk. */
function reverseEdges(graph: Graph, options: TraversalOptions): readonly GraphEdge[] {
return options.includeNav
return own(options, "includeNav")
Comment thread
Fcmam5 marked this conversation as resolved.
Resolved
? graph.edges
: graph.edges.filter((e) => !isReservedFile(e.from));
}
Expand Down
Loading
Loading