knodin 0.7.6 → 0.8.2
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/README.md +19 -7
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2164 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +14238 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +106 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +120 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/response-budget.js +196 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1640 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +59 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +127 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Resolves where a repository's knodin state (graph database, wiki, hook
|
|
6
|
+
* scaffolding) lives.
|
|
7
|
+
*
|
|
8
|
+
* A normal checkout keeps its state in-tree at `<repo>/.knodin`, unchanged from
|
|
9
|
+
* the original hardcoded behaviour. A *mirror* — a read-only shallow clone of a
|
|
10
|
+
* repository the user never checked out themselves — keeps its state outside the
|
|
11
|
+
* clone entirely, so that a refetch can discard and replace the source tree
|
|
12
|
+
* without destroying a graph that took minutes to build.
|
|
13
|
+
*
|
|
14
|
+
* Mirror layout, rooted at {@link mirrorRoot}:
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* <root>/registry.json the mirror index
|
|
18
|
+
* <root>/.staging/<id> in-progress clones, renamed into place on success
|
|
19
|
+
* <root>/<id>/source the shallow clone
|
|
20
|
+
* <root>/<id>/state db.sqlite, wiki/, and friends
|
|
21
|
+
* <root>/<id>/README marker: local edits here are discarded on refresh
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Every consumer must route through {@link resolveStateDir} /
|
|
25
|
+
* {@link resolveDbPath} rather than rebuilding `path.join(repo, ".knodin")`
|
|
26
|
+
* inline, or mirrors silently read from the wrong location.
|
|
27
|
+
*/
|
|
28
|
+
/** Directory name holding the shallow clone inside a mirror entry. */
|
|
29
|
+
export const MIRROR_SOURCE_DIR = "source";
|
|
30
|
+
/** Directory name holding graph state inside a mirror entry. */
|
|
31
|
+
export const MIRROR_STATE_DIR = "state";
|
|
32
|
+
/** In-tree state directory name for a normal checkout. */
|
|
33
|
+
export const IN_TREE_STATE_DIR = ".knodin";
|
|
34
|
+
const REGISTRY_VERSION = 1;
|
|
35
|
+
const EMPTY_REGISTRY = { version: REGISTRY_VERSION, mirrors: [] };
|
|
36
|
+
/**
|
|
37
|
+
* Root for all mirror storage. Defaults to `~/.knodin/mirrors` rather than an
|
|
38
|
+
* XDG data directory: a mirror costs minutes of indexing to rebuild, so it needs
|
|
39
|
+
* to sit somewhere the user will actually notice and can audit with
|
|
40
|
+
* `knodin remote list`. `KNODIN_MIRROR_ROOT` overrides it.
|
|
41
|
+
*/
|
|
42
|
+
export function mirrorRoot() {
|
|
43
|
+
const override = process.env.KNODIN_MIRROR_ROOT;
|
|
44
|
+
return override ? path.resolve(override) : path.join(os.homedir(), ".knodin", "mirrors");
|
|
45
|
+
}
|
|
46
|
+
/** Path to the mirror registry document. */
|
|
47
|
+
export function mirrorRegistryPath() {
|
|
48
|
+
return path.join(mirrorRoot(), "registry.json");
|
|
49
|
+
}
|
|
50
|
+
/** Root for in-progress clones, renamed into place only once complete. */
|
|
51
|
+
export function mirrorStagingRoot() {
|
|
52
|
+
return path.join(mirrorRoot(), ".staging");
|
|
53
|
+
}
|
|
54
|
+
/** Entry root for one mirror: the parent of both `source/` and `state/`. */
|
|
55
|
+
export function mirrorEntryPath(identity) {
|
|
56
|
+
return path.join(mirrorRoot(), identity);
|
|
57
|
+
}
|
|
58
|
+
/** Absolute path to a mirror's shallow clone. */
|
|
59
|
+
export function mirrorSourcePath(identity) {
|
|
60
|
+
return path.join(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
|
|
61
|
+
}
|
|
62
|
+
/** Absolute path to a mirror's graph state directory. */
|
|
63
|
+
export function mirrorStatePath(identity) {
|
|
64
|
+
return path.join(mirrorEntryPath(identity), MIRROR_STATE_DIR);
|
|
65
|
+
}
|
|
66
|
+
let cache = null;
|
|
67
|
+
function isMirrorRecord(value) {
|
|
68
|
+
if (typeof value !== "object" || value === null)
|
|
69
|
+
return false;
|
|
70
|
+
const record = value;
|
|
71
|
+
return (typeof record.identity === "string" &&
|
|
72
|
+
typeof record.url === "string" &&
|
|
73
|
+
typeof record.path === "string" &&
|
|
74
|
+
typeof record.fetchedAt === "string" &&
|
|
75
|
+
typeof record.sha === "string");
|
|
76
|
+
}
|
|
77
|
+
function indexByPath(registry) {
|
|
78
|
+
const byPath = new Map();
|
|
79
|
+
for (const record of registry.mirrors)
|
|
80
|
+
byPath.set(path.resolve(record.path), record);
|
|
81
|
+
return byPath;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads the mirror registry, memoized against the file's mtime and size so a
|
|
85
|
+
* mirror added by a concurrent `knodin remote add` is picked up without paying a
|
|
86
|
+
* JSON parse on every path resolution.
|
|
87
|
+
*
|
|
88
|
+
* The memo key is (mtimeMs, size), which is a heuristic rather than a guarantee:
|
|
89
|
+
* an out-of-process write landing in the same millisecond AND producing the same
|
|
90
|
+
* byte length would not invalidate it. Accepted deliberately — that requires a
|
|
91
|
+
* concurrent edit of identical length within one millisecond, the in-process
|
|
92
|
+
* writer drops the memo explicitly, and the cost of being wrong is one stale
|
|
93
|
+
* read, not corruption. Recorded so it reads as a decision, not an oversight.
|
|
94
|
+
*
|
|
95
|
+
* A missing or corrupt registry reads as empty. That is deliberate too: it
|
|
96
|
+
* degrades to "no mirrors configured", which routes every repository to its
|
|
97
|
+
* in-tree state — the pre-existing behaviour — rather than failing a query
|
|
98
|
+
* outright.
|
|
99
|
+
*/
|
|
100
|
+
export function readMirrorRegistry() {
|
|
101
|
+
const file = mirrorRegistryPath();
|
|
102
|
+
let stat;
|
|
103
|
+
try {
|
|
104
|
+
stat = fs.statSync(file);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
cache = null;
|
|
108
|
+
return EMPTY_REGISTRY;
|
|
109
|
+
}
|
|
110
|
+
if (cache?.mtimeMs === stat.mtimeMs && cache?.size === stat.size)
|
|
111
|
+
return cache.registry;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
114
|
+
const mirrors = typeof parsed === "object" &&
|
|
115
|
+
parsed !== null &&
|
|
116
|
+
Array.isArray(parsed.mirrors)
|
|
117
|
+
? parsed.mirrors.filter(isMirrorRecord)
|
|
118
|
+
: [];
|
|
119
|
+
const registry = { version: REGISTRY_VERSION, mirrors };
|
|
120
|
+
cache = { mtimeMs: stat.mtimeMs, size: stat.size, registry, byPath: indexByPath(registry) };
|
|
121
|
+
return registry;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
cache = null;
|
|
125
|
+
return EMPTY_REGISTRY;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Persists the registry and drops the memo so the next read reloads it. */
|
|
129
|
+
export function writeMirrorRegistry(registry) {
|
|
130
|
+
const file = mirrorRegistryPath();
|
|
131
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
132
|
+
const document = { version: REGISTRY_VERSION, mirrors: registry.mirrors };
|
|
133
|
+
fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
|
|
134
|
+
cache = null;
|
|
135
|
+
}
|
|
136
|
+
/** Clears the in-process registry memo. Test seam. */
|
|
137
|
+
export function __resetMirrorRegistryCache() {
|
|
138
|
+
cache = null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Returns the mirror record whose clone is at `repoPath`, if any. Matching is on
|
|
142
|
+
* the resolved path, so a caller passing a relative or unnormalized path still
|
|
143
|
+
* resolves correctly.
|
|
144
|
+
*/
|
|
145
|
+
export function lookupMirror(repoPath) {
|
|
146
|
+
readMirrorRegistry();
|
|
147
|
+
return cache?.byPath.get(path.resolve(repoPath));
|
|
148
|
+
}
|
|
149
|
+
/** True when `repoPath` is a registered read-only mirror clone. */
|
|
150
|
+
export function isMirror(repoPath) {
|
|
151
|
+
return lookupMirror(repoPath) !== undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Directory holding knodin state for `repoPath`: in-tree `.knodin` for a normal
|
|
155
|
+
* checkout, an out-of-tree sibling of the clone for a mirror.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveStateDir(repoPath) {
|
|
158
|
+
const resolved = path.resolve(repoPath);
|
|
159
|
+
const mirror = lookupMirror(resolved);
|
|
160
|
+
if (mirror)
|
|
161
|
+
return mirrorStatePath(mirror.identity);
|
|
162
|
+
return path.join(resolved, IN_TREE_STATE_DIR);
|
|
163
|
+
}
|
|
164
|
+
/** Graph database path for `repoPath`. */
|
|
165
|
+
export function resolveDbPath(repoPath) {
|
|
166
|
+
return path.join(resolveStateDir(repoPath), "db.sqlite");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Whether opening an index for `repoPath` may write to the repository itself —
|
|
170
|
+
* false for mirrors, whose source tree is replaced wholesale on refresh and must
|
|
171
|
+
* stay byte-identical to the remote in the meantime.
|
|
172
|
+
*/
|
|
173
|
+
export function mayWriteToRepository(repoPath) {
|
|
174
|
+
return !isMirror(repoPath);
|
|
175
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "delete symbols, and everything keyed off them" (R17).
|
|
3
|
+
*
|
|
4
|
+
* `symbol_embeddings.symbolId` declares
|
|
5
|
+
* `REFERENCES symbols(id) ON DELETE CASCADE`, but SQLite disables foreign-key
|
|
6
|
+
* enforcement per connection by default, so for the life of the project that
|
|
7
|
+
* cascade never fired: `symbols.id` is AUTOINCREMENT, every re-index of a file
|
|
8
|
+
* deletes its symbols and reinserts them under NEW ids, and the embeddings
|
|
9
|
+
* pointing at the old ids were stranded forever. On `/path/to/large-repository` that had
|
|
10
|
+
* reached 37,258 orphans against 18,629 live symbols — 67% of the table, all
|
|
11
|
+
* of it scanned by semantic search's exact cosine pass.
|
|
12
|
+
*
|
|
13
|
+
* `getOrInitDb` now sets `PRAGMA foreign_keys = ON`, which makes the declared
|
|
14
|
+
* cascade real. These helpers are the second layer: they delete the embeddings
|
|
15
|
+
* EXPLICITLY, so correctness does not depend on a per-connection pragma that
|
|
16
|
+
* some future connection path forgets to set. Every `DELETE FROM symbols` site
|
|
17
|
+
* in the engine goes through here — the R14 lesson (one policy, many thin
|
|
18
|
+
* adapters) applied to deletion instead of ignore rules.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Shared predicate for "this embedding's symbol no longer exists" — the
|
|
22
|
+
* single source of truth for both the DELETE below and the `stats` orphan
|
|
23
|
+
* COUNT in `src/engine/index.ts`, which previously hardcoded the identical
|
|
24
|
+
* SQL as an independent string literal.
|
|
25
|
+
*/
|
|
26
|
+
export const ORPHANED_EMBEDDING_PREDICATE = "symbolId NOT IN (SELECT id FROM symbols)";
|
|
27
|
+
/**
|
|
28
|
+
* Purge embeddings whose symbol no longer exists.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent and cheap — a single correlated DELETE. Used by the 13 → 14
|
|
31
|
+
* migration to clean indexes polluted before the cascade worked, and safe to
|
|
32
|
+
* call on an empty or freshly-created table.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately does NOT touch live embeddings: re-embedding a real corpus is
|
|
35
|
+
* ~40 minutes of CPU, so this must stay a purge and never become a wipe.
|
|
36
|
+
*/
|
|
37
|
+
export function purgeOrphanEmbeddings(db) {
|
|
38
|
+
db.run(`DELETE FROM symbol_embeddings WHERE ${ORPHANED_EMBEDDING_PREDICATE}`);
|
|
39
|
+
}
|
|
40
|
+
/** Delete every symbol declared in one repo-relative file, plus its embeddings. */
|
|
41
|
+
export function deleteSymbolsForFile(db, filePath) {
|
|
42
|
+
db.run("DELETE FROM symbol_embeddings WHERE symbolId IN (SELECT id FROM symbols WHERE filePath = ?)", [filePath]);
|
|
43
|
+
db.run("DELETE FROM symbols WHERE filePath = ?", [filePath]);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Delete every symbol whose file path matches a LIKE pattern, plus its
|
|
47
|
+
* embeddings. Used by the dbt path, whose symbols live under synthetic
|
|
48
|
+
* `.knodin/dbt/%` paths rather than one real file.
|
|
49
|
+
*/
|
|
50
|
+
export function deleteSymbolsMatchingPath(db, likePattern) {
|
|
51
|
+
db.run("DELETE FROM symbol_embeddings WHERE symbolId IN (SELECT id FROM symbols WHERE filePath LIKE ?)", [likePattern]);
|
|
52
|
+
db.run("DELETE FROM symbols WHERE filePath LIKE ?", [likePattern]);
|
|
53
|
+
}
|
|
54
|
+
/** Delete every symbol in the index, plus every embedding. Full re-index only. */
|
|
55
|
+
export function deleteAllSymbols(db) {
|
|
56
|
+
db.run("DELETE FROM symbol_embeddings;");
|
|
57
|
+
db.run("DELETE FROM symbols;");
|
|
58
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { compressOutput } from "./output-compression.js";
|
|
6
|
+
function defaultGlobalConfigPath() {
|
|
7
|
+
const base = process.env.XDG_CONFIG_HOME
|
|
8
|
+
? path.resolve(process.env.XDG_CONFIG_HOME)
|
|
9
|
+
: path.join(os.homedir(), ".config");
|
|
10
|
+
return path.join(base, "knodin", "execution.json");
|
|
11
|
+
}
|
|
12
|
+
function readDocument(file) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function sandboxLiteral(value) {
|
|
21
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
22
|
+
}
|
|
23
|
+
function macosPolicy(repo, _executable) {
|
|
24
|
+
const protectedRoots = ["/Users", "/Volumes", "/Network", "/private/tmp", "/private/var/folders"];
|
|
25
|
+
const protectedSubpaths = protectedRoots
|
|
26
|
+
.map((item) => `(subpath ${sandboxLiteral(item)})`)
|
|
27
|
+
.join(" ");
|
|
28
|
+
return [
|
|
29
|
+
"(version 1)",
|
|
30
|
+
"(allow default)",
|
|
31
|
+
"(deny network*)",
|
|
32
|
+
"(deny process-fork)",
|
|
33
|
+
`(deny file-read* ${protectedSubpaths})`,
|
|
34
|
+
"(deny file-write*)",
|
|
35
|
+
`(allow file-read* file-write* (subpath ${sandboxLiteral(repo)}) (literal "/dev/null"))`,
|
|
36
|
+
].join(" ");
|
|
37
|
+
}
|
|
38
|
+
function unavailable(profile, warning) {
|
|
39
|
+
return {
|
|
40
|
+
schemaVersion: 1,
|
|
41
|
+
status: "unavailable",
|
|
42
|
+
profile,
|
|
43
|
+
exit: { code: null, signal: null },
|
|
44
|
+
containment: {
|
|
45
|
+
level: "containment-unavailable",
|
|
46
|
+
platform: process.platform,
|
|
47
|
+
adapter: "unavailable",
|
|
48
|
+
filesystem: "unavailable",
|
|
49
|
+
network: "unavailable",
|
|
50
|
+
processes: "unavailable",
|
|
51
|
+
warning,
|
|
52
|
+
},
|
|
53
|
+
limits: { timeoutMs: 0, maxOutputBytes: 0, maxProcesses: 1 },
|
|
54
|
+
output: null,
|
|
55
|
+
audit: { commandTextRecorded: false, profileRecorded: true },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function validateProfile(profileName, value) {
|
|
59
|
+
if (!value || typeof value !== "object")
|
|
60
|
+
throw new Error(`execution profile $profileNameis invalid`);
|
|
61
|
+
const profile = value;
|
|
62
|
+
if (!path.isAbsolute(profile.executable ?? ""))
|
|
63
|
+
throw new Error(`execution profile $profileName: executable must be absolute`);
|
|
64
|
+
if (!Array.isArray(profile.args) ||
|
|
65
|
+
profile.args.some((arg) => typeof arg !== "string" || arg.includes("\0")))
|
|
66
|
+
throw new Error(`execution profile ${profileName}: args must be a NUL-free string array`);
|
|
67
|
+
if (profile.network !== "deny")
|
|
68
|
+
throw new Error(`execution profile ${profileName}: network must be deny`);
|
|
69
|
+
if (!Number.isInteger(profile.timeoutMs) ||
|
|
70
|
+
(profile.timeoutMs ?? 0) < 1 ||
|
|
71
|
+
(profile.timeoutMs ?? 0) > 300_000)
|
|
72
|
+
throw new Error(`execution profile ${profileName}: timeoutMs must be 1..300000`);
|
|
73
|
+
if (!Number.isInteger(profile.maxOutputBytes) ||
|
|
74
|
+
(profile.maxOutputBytes ?? 0) < 256 ||
|
|
75
|
+
(profile.maxOutputBytes ?? 0) > 16 * 1024 * 1024)
|
|
76
|
+
throw new Error(`execution profile ${profileName}: maxOutputBytes must be 256..16777216`);
|
|
77
|
+
if (profile.maxProcesses !== 1)
|
|
78
|
+
throw new Error(`execution profile ${profileName}: maxProcesses must be 1 for hard fork denial`);
|
|
79
|
+
if (profile.containment !== undefined && profile.containment !== "fully-contained")
|
|
80
|
+
throw new Error(`execution profile ${profileName}: only fully-contained is supported`);
|
|
81
|
+
return profile;
|
|
82
|
+
}
|
|
83
|
+
function appendAudit(repo, record) {
|
|
84
|
+
const destination = path.join(repo, ".knodin", "execution-audit.jsonl");
|
|
85
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
86
|
+
fs.appendFileSync(destination, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
export async function runExecutionProfile(options) {
|
|
89
|
+
const repo = fs.realpathSync(options.repo);
|
|
90
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(options.profile))
|
|
91
|
+
throw new Error("execution profile identity must match [a-z][a-z0-9_-]{0,63}");
|
|
92
|
+
const globalDocument = readDocument(options.globalConfigPath ?? defaultGlobalConfigPath());
|
|
93
|
+
const repositoryDocument = readDocument(path.join(repo, ".knodin", "execution.json"));
|
|
94
|
+
if (globalDocument?.execution?.enabled !== true)
|
|
95
|
+
return unavailable(options.profile, "execution is disabled globally");
|
|
96
|
+
if (repositoryDocument?.execution?.enabled !== true)
|
|
97
|
+
return unavailable(options.profile, "execution is disabled for this repository");
|
|
98
|
+
const configured = repositoryDocument.execution.profiles?.[options.profile];
|
|
99
|
+
if (!configured)
|
|
100
|
+
throw new Error(`execution profile not found: ${options.profile}`);
|
|
101
|
+
const profile = validateProfile(options.profile, configured);
|
|
102
|
+
if (process.platform !== "darwin" || !fs.existsSync("/usr/bin/sandbox-exec"))
|
|
103
|
+
return unavailable(options.profile, "no certified containment adapter is available on this platform");
|
|
104
|
+
const executable = fs.realpathSync(profile.executable);
|
|
105
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(repo, ".knodin", "execution-tmp-"));
|
|
106
|
+
const environment = {
|
|
107
|
+
PATH: `${path.dirname(executable)}:/usr/bin:/bin`,
|
|
108
|
+
LANG: "C.UTF-8",
|
|
109
|
+
LC_ALL: "C.UTF-8",
|
|
110
|
+
TMPDIR: temporaryDirectory,
|
|
111
|
+
};
|
|
112
|
+
const policy = macosPolicy(repo, executable);
|
|
113
|
+
const proc = childProcess.spawn("/usr/bin/sandbox-exec", ["-p", policy, executable, ...profile.args], {
|
|
114
|
+
cwd: repo,
|
|
115
|
+
env: environment,
|
|
116
|
+
shell: false,
|
|
117
|
+
detached: true,
|
|
118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
119
|
+
});
|
|
120
|
+
let stdout = Buffer.alloc(0);
|
|
121
|
+
let stderr = Buffer.alloc(0);
|
|
122
|
+
let reason = "exit";
|
|
123
|
+
let stopped = false;
|
|
124
|
+
const stop = (next) => {
|
|
125
|
+
if (stopped || reason !== "exit")
|
|
126
|
+
return;
|
|
127
|
+
reason = next;
|
|
128
|
+
if (proc.pid)
|
|
129
|
+
try {
|
|
130
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
};
|
|
134
|
+
const append = (stream, chunk) => {
|
|
135
|
+
const remaining = Math.max(0, profile.maxOutputBytes - stdout.length - stderr.length);
|
|
136
|
+
const accepted = chunk.subarray(0, remaining);
|
|
137
|
+
if (stream === "stdout")
|
|
138
|
+
stdout = Buffer.concat([stdout, accepted]);
|
|
139
|
+
else
|
|
140
|
+
stderr = Buffer.concat([stderr, accepted]);
|
|
141
|
+
if (accepted.length < chunk.length)
|
|
142
|
+
stop("output-limit");
|
|
143
|
+
};
|
|
144
|
+
proc.stdout.on("data", (chunk) => append("stdout", chunk));
|
|
145
|
+
proc.stderr.on("data", (chunk) => append("stderr", chunk));
|
|
146
|
+
const timer = setTimeout(() => stop("timeout"), profile.timeoutMs);
|
|
147
|
+
const closed = await new Promise((resolve, reject) => {
|
|
148
|
+
proc.once("error", reject);
|
|
149
|
+
proc.once("close", (code, signal) => resolve({ code, signal }));
|
|
150
|
+
});
|
|
151
|
+
stopped = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
154
|
+
const combined = [
|
|
155
|
+
...(stdout.length ? [{ stream: "stdout", text: stdout.toString("utf8") }] : []),
|
|
156
|
+
...(stderr.length ? [{ stream: "stderr", text: stderr.toString("utf8") }] : []),
|
|
157
|
+
];
|
|
158
|
+
const output = compressOutput(repo, {
|
|
159
|
+
events: combined,
|
|
160
|
+
exitCode: closed.code,
|
|
161
|
+
signal: closed.signal,
|
|
162
|
+
lineBudget: 200,
|
|
163
|
+
byteBudget: Math.min(profile.maxOutputBytes, 16_384),
|
|
164
|
+
retain: true,
|
|
165
|
+
});
|
|
166
|
+
const finalReason = reason;
|
|
167
|
+
// A non-zero exit only reads as "failed" once the bounded-run reasons that
|
|
168
|
+
// preempt it (deadline, output cap) have been ruled out.
|
|
169
|
+
let status;
|
|
170
|
+
if (finalReason === "timeout")
|
|
171
|
+
status = "timed-out";
|
|
172
|
+
else if (finalReason === "output-limit")
|
|
173
|
+
status = "output-limit";
|
|
174
|
+
else
|
|
175
|
+
status = closed.code === 0 ? "completed" : "failed";
|
|
176
|
+
const result = {
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
status,
|
|
179
|
+
profile: options.profile,
|
|
180
|
+
exit: closed,
|
|
181
|
+
containment: {
|
|
182
|
+
level: "fully-contained",
|
|
183
|
+
platform: process.platform,
|
|
184
|
+
adapter: "macos-sandbox-exec",
|
|
185
|
+
filesystem: "repository-write-user-data-read-bounded",
|
|
186
|
+
network: "deny",
|
|
187
|
+
processes: "fork-denied",
|
|
188
|
+
warning: "macOS sandbox-exec is deprecated. Writes are repository-bound; system runtime paths remain readable while user homes, mounted volumes, network paths, and shared temporary roots are read-denied outside the repository.",
|
|
189
|
+
},
|
|
190
|
+
limits: {
|
|
191
|
+
timeoutMs: profile.timeoutMs,
|
|
192
|
+
maxOutputBytes: profile.maxOutputBytes,
|
|
193
|
+
maxProcesses: 1,
|
|
194
|
+
},
|
|
195
|
+
output,
|
|
196
|
+
audit: { commandTextRecorded: false, profileRecorded: true },
|
|
197
|
+
};
|
|
198
|
+
appendAudit(repo, {
|
|
199
|
+
timestamp: new Date().toISOString(),
|
|
200
|
+
profile: options.profile,
|
|
201
|
+
status,
|
|
202
|
+
exitCode: closed.code,
|
|
203
|
+
signal: closed.signal,
|
|
204
|
+
containment: result.containment.level,
|
|
205
|
+
outputArtifactId: output.artifact.id,
|
|
206
|
+
});
|
|
207
|
+
return result;
|
|
208
|
+
}
|