kritya 0.8.2-beta
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 +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Write a file without ever leaving a truncated one behind.
|
|
6
|
+
*
|
|
7
|
+
* `fs.writeFile` to the target truncates it first and then fills it back in.
|
|
8
|
+
* Anything that interrupts the gap — a crash, a kill switch, a power cut, a
|
|
9
|
+
* full disk partway through a large file — leaves the user's source file
|
|
10
|
+
* destroyed, with the original recoverable only from the in-memory undo stack
|
|
11
|
+
* of the process that just died. Writing a sibling temp file and renaming it
|
|
12
|
+
* over the target closes that window: rename is a single filesystem metadata
|
|
13
|
+
* operation, so a reader sees either the whole old file or the whole new one.
|
|
14
|
+
*
|
|
15
|
+
* Three details this has to get right that a bare rename does not:
|
|
16
|
+
*
|
|
17
|
+
* - **Symlinks.** `fs.writeFile` follows them and writes through to the
|
|
18
|
+
* target; a rename would replace the link itself. The real path is
|
|
19
|
+
* resolved first so the observable behavior is unchanged.
|
|
20
|
+
* - **Permissions.** A renamed-in file carries the temp file's mode, not the
|
|
21
|
+
* original's, which would silently strip the executable bit off a script.
|
|
22
|
+
* The existing mode is copied across before the rename.
|
|
23
|
+
* - **Windows.** A rename onto a file another process holds open (an editor,
|
|
24
|
+
* an indexer, antivirus) fails with EPERM/EBUSY where a plain write would
|
|
25
|
+
* have succeeded. That must not turn a working write into an error, so it
|
|
26
|
+
* falls back to writing in place — no worse than the old behavior, and only
|
|
27
|
+
* on the paths where the old behavior was the only option.
|
|
28
|
+
*
|
|
29
|
+
* Durability is deliberately not part of the contract: there is no fsync, so a
|
|
30
|
+
* power loss can still lose a just-written file on some filesystems. The
|
|
31
|
+
* hazard being closed here is truncation, which is both far likelier and far
|
|
32
|
+
* more destructive, and fsync on every edit is a cost every write would pay.
|
|
33
|
+
*/
|
|
34
|
+
/** Codes that mean "the target is locked", not "the write is wrong". */
|
|
35
|
+
const RENAME_FALLBACK_CODES = new Set(["EPERM", "EACCES", "EBUSY", "EEXIST", "ENOTEMPTY"]);
|
|
36
|
+
function tempPathFor(filePath) {
|
|
37
|
+
const unique = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
38
|
+
return path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${unique}`);
|
|
39
|
+
}
|
|
40
|
+
/** Resolve symlinks so we replace what the path points at, not the link. */
|
|
41
|
+
function resolveTarget(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.realpathSync(filePath);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return filePath; // doesn't exist yet — nothing to follow
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The mode to give the replacement, when the file already exists. */
|
|
50
|
+
function existingMode(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(filePath).mode & 0o777;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function writeFileAtomic(filePath, data, options = {}) {
|
|
59
|
+
const target = resolveTarget(filePath);
|
|
60
|
+
const mode = options.mode ?? existingMode(target);
|
|
61
|
+
const tmp = tempPathFor(target);
|
|
62
|
+
try {
|
|
63
|
+
await fsp.writeFile(tmp, data, options.encoding ?? (typeof data === "string" ? "utf8" : null));
|
|
64
|
+
if (mode !== undefined)
|
|
65
|
+
await fsp.chmod(tmp, mode);
|
|
66
|
+
await fsp.rename(tmp, target);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
await fsp.rm(tmp, { force: true }).catch(() => { });
|
|
70
|
+
if (!isRenameFallback(err))
|
|
71
|
+
throw err;
|
|
72
|
+
await fsp.writeFile(filePath, data, options.encoding ?? (typeof data === "string" ? "utf8" : null));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function writeFileAtomicSync(filePath, data, options = {}) {
|
|
76
|
+
const target = resolveTarget(filePath);
|
|
77
|
+
const mode = options.mode ?? existingMode(target);
|
|
78
|
+
const tmp = tempPathFor(target);
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(tmp, data, {
|
|
81
|
+
...(options.encoding ? { encoding: options.encoding } : {}),
|
|
82
|
+
...(mode !== undefined ? { mode } : {}),
|
|
83
|
+
});
|
|
84
|
+
// writeFileSync's `mode` only applies when it creates the file, and umask
|
|
85
|
+
// can mask bits off it — set it explicitly so the replacement really does
|
|
86
|
+
// carry the original's permissions.
|
|
87
|
+
if (mode !== undefined)
|
|
88
|
+
fs.chmodSync(tmp, mode);
|
|
89
|
+
fs.renameSync(tmp, target);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
try {
|
|
93
|
+
fs.rmSync(tmp, { force: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Nothing more to do; the write result below is what matters.
|
|
97
|
+
}
|
|
98
|
+
if (!isRenameFallback(err))
|
|
99
|
+
throw err;
|
|
100
|
+
fs.writeFileSync(filePath, data, {
|
|
101
|
+
...(options.encoding ? { encoding: options.encoding } : {}),
|
|
102
|
+
...(mode !== undefined ? { mode } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isRenameFallback(err) {
|
|
107
|
+
const code = err?.code;
|
|
108
|
+
return !!code && RENAME_FALLBACK_CODES.has(code);
|
|
109
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
5
|
+
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
6
|
+
import { debugLog } from "../config/debug.js";
|
|
7
|
+
const GENESIS = "0".repeat(64);
|
|
8
|
+
/**
|
|
9
|
+
* Where all audit logs live, across every workspace (not scoped per-project).
|
|
10
|
+
* KRITYA_AUDIT_DIR overrides it — used by tests so cleanupOld's directory
|
|
11
|
+
* scan (and any future directory-wide operation) never touches the real
|
|
12
|
+
* ~/.kritya/audit on a dev machine.
|
|
13
|
+
*/
|
|
14
|
+
export function auditDir() {
|
|
15
|
+
return process.env.KRITYA_AUDIT_DIR || path.join(CONFIG_DIR, "audit");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve where audit logs are written for this session, honoring an explicit
|
|
19
|
+
* override. Kept separate so tests can point it at a temp dir.
|
|
20
|
+
*/
|
|
21
|
+
function defaultAuditFile(sessionId) {
|
|
22
|
+
const override = process.env.KRITYA_AUDIT_FILE;
|
|
23
|
+
if (override)
|
|
24
|
+
return override;
|
|
25
|
+
return path.join(auditDir(), `${sessionId}.audit.jsonl`);
|
|
26
|
+
}
|
|
27
|
+
export class AuditLog {
|
|
28
|
+
sessionId;
|
|
29
|
+
file;
|
|
30
|
+
seq = 0;
|
|
31
|
+
prevHash = GENESIS;
|
|
32
|
+
ready = false;
|
|
33
|
+
constructor(sessionId, file = defaultAuditFile(sessionId)) {
|
|
34
|
+
this.sessionId = sessionId;
|
|
35
|
+
this.file = file;
|
|
36
|
+
}
|
|
37
|
+
/** Where this session's records are written, for callers that report it. */
|
|
38
|
+
get path() {
|
|
39
|
+
return this.file;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Off by default only when explicitly disabled; auditing is on otherwise so
|
|
43
|
+
* the trail exists without anyone having to opt in. Returns undefined when
|
|
44
|
+
* disabled so callers can leave `agent.audit` unset.
|
|
45
|
+
*
|
|
46
|
+
* `configDefault` is config.json's persisted `audit` setting, so a user can
|
|
47
|
+
* turn auditing off for good without exporting KRITYA_AUDIT every launch —
|
|
48
|
+
* the env var still wins over it when set, for a one-off override.
|
|
49
|
+
*/
|
|
50
|
+
static forSession(sessionId, configDefault) {
|
|
51
|
+
const envVal = (process.env.KRITYA_AUDIT ?? "").toLowerCase();
|
|
52
|
+
const disabled = envVal ? envVal === "off" : configDefault === "off";
|
|
53
|
+
if (disabled)
|
|
54
|
+
return undefined;
|
|
55
|
+
return new AuditLog(sessionId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Delete audit log files older than `retentionDays`. Best-effort, same
|
|
59
|
+
* pattern as SessionStore.cleanupOldSessions. 0 or negative means "keep
|
|
60
|
+
* forever" — auto-delete is disabled, not an immediate-expiry window.
|
|
61
|
+
*/
|
|
62
|
+
static cleanupOld(retentionDays) {
|
|
63
|
+
if (retentionDays <= 0)
|
|
64
|
+
return;
|
|
65
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
66
|
+
let files;
|
|
67
|
+
try {
|
|
68
|
+
files = fs.readdirSync(auditDir()).filter((f) => f.endsWith(".audit.jsonl"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
const file = path.join(auditDir(), f);
|
|
75
|
+
try {
|
|
76
|
+
if (fs.statSync(file).mtimeMs < cutoff)
|
|
77
|
+
fs.unlinkSync(file);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
debugLog(`AuditLog.cleanupOld(${file})`, err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
logPermission(entry) {
|
|
85
|
+
this.write({ event: "permission", ...entry });
|
|
86
|
+
}
|
|
87
|
+
logTool(entry) {
|
|
88
|
+
this.write({ event: "tool", ...entry });
|
|
89
|
+
}
|
|
90
|
+
write(partial) {
|
|
91
|
+
// Best-effort, exactly like SessionStore.append: an audit failure must
|
|
92
|
+
// never crash a turn. A dropped record is preferable to a broken agent.
|
|
93
|
+
try {
|
|
94
|
+
this.ensureDir();
|
|
95
|
+
const base = {
|
|
96
|
+
ts: new Date().toISOString(),
|
|
97
|
+
seq: this.seq++,
|
|
98
|
+
sessionId: this.sessionId,
|
|
99
|
+
...partial,
|
|
100
|
+
};
|
|
101
|
+
const hash = crypto
|
|
102
|
+
.createHash("sha256")
|
|
103
|
+
.update(this.prevHash + JSON.stringify(base))
|
|
104
|
+
.digest("hex");
|
|
105
|
+
const record = { ...base, prevHash: this.prevHash, hash };
|
|
106
|
+
fs.appendFileSync(this.file, JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
107
|
+
this.prevHash = hash;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// best-effort
|
|
111
|
+
debugLog(`AuditLog.write(${this.file})`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
ensureDir() {
|
|
115
|
+
if (this.ready)
|
|
116
|
+
return;
|
|
117
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
118
|
+
hardenWindowsDir(auditDir());
|
|
119
|
+
this.ready = true;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every valid record in a written audit file, in order. Corrupt lines are
|
|
123
|
+
* skipped (mirrors SessionStore's tolerance for a truncated last line), so
|
|
124
|
+
* this is for display purposes — use verify() to confirm the file wasn't
|
|
125
|
+
* silently altered.
|
|
126
|
+
*/
|
|
127
|
+
static readRecords(file) {
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = fs.readFileSync(file, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const records = [];
|
|
136
|
+
for (const line of raw.split("\n")) {
|
|
137
|
+
if (!line.trim())
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
records.push(JSON.parse(line));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// skip corrupt lines
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return records;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Re-walk a written audit file and confirm the hash chain is intact.
|
|
150
|
+
*
|
|
151
|
+
* Deliberately distinguishes "the file doesn't exist / can't be read" from
|
|
152
|
+
* "the chain verifies clean" — collapsing those two cases (as an earlier
|
|
153
|
+
* version of this method did, both returning -1) means deleting the whole
|
|
154
|
+
* audit log would report the same as an untampered one, which is the
|
|
155
|
+
* easiest way to defeat a tamper-evident log: don't edit it, remove it.
|
|
156
|
+
*/
|
|
157
|
+
static verify(file) {
|
|
158
|
+
let raw;
|
|
159
|
+
try {
|
|
160
|
+
raw = fs.readFileSync(file, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { ok: false, reason: "unreadable" };
|
|
164
|
+
}
|
|
165
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
166
|
+
let prevHash = GENESIS;
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
let rec;
|
|
169
|
+
try {
|
|
170
|
+
rec = JSON.parse(lines[i]);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { ok: false, reason: "tampered", line: i };
|
|
174
|
+
}
|
|
175
|
+
const { hash, prevHash: recPrev, ...base } = rec;
|
|
176
|
+
if (recPrev !== prevHash)
|
|
177
|
+
return { ok: false, reason: "tampered", line: i };
|
|
178
|
+
const expected = crypto
|
|
179
|
+
.createHash("sha256")
|
|
180
|
+
.update(prevHash + JSON.stringify(base))
|
|
181
|
+
.digest("hex");
|
|
182
|
+
if (expected !== hash)
|
|
183
|
+
return { ok: false, reason: "tampered", line: i };
|
|
184
|
+
prevHash = hash;
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, records: lines.length };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Sorted-array percentile (nearest-rank). Returns 0 for an empty array. */
|
|
190
|
+
function percentile(sortedValues, p) {
|
|
191
|
+
if (!sortedValues.length)
|
|
192
|
+
return 0;
|
|
193
|
+
const idx = Math.min(sortedValues.length - 1, Math.ceil((p / 100) * sortedValues.length) - 1);
|
|
194
|
+
return sortedValues[Math.max(0, idx)];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A one-shot statistical rollup of an audit log — counts and latency
|
|
198
|
+
* percentiles — computed on demand from the existing records rather than
|
|
199
|
+
* persisted as its own record, so there's nothing new to keep in sync with
|
|
200
|
+
* the hash chain. Used by `/audit` and `kritya audit --summary`.
|
|
201
|
+
*/
|
|
202
|
+
export function summarizeAudit(records) {
|
|
203
|
+
const toolCallsByOutcome = {};
|
|
204
|
+
const permissionsBySource = {};
|
|
205
|
+
const durations = [];
|
|
206
|
+
const waits = [];
|
|
207
|
+
for (const r of records) {
|
|
208
|
+
if (r.event === "tool") {
|
|
209
|
+
toolCallsByOutcome[r.outcome] = (toolCallsByOutcome[r.outcome] ?? 0) + 1;
|
|
210
|
+
if (r.outcome === "ok" && typeof r.durationMs === "number")
|
|
211
|
+
durations.push(r.durationMs);
|
|
212
|
+
if (typeof r.waitMs === "number")
|
|
213
|
+
waits.push(r.waitMs);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
permissionsBySource[r.source] = (permissionsBySource[r.source] ?? 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
durations.sort((a, b) => a - b);
|
|
220
|
+
waits.sort((a, b) => a - b);
|
|
221
|
+
return {
|
|
222
|
+
totalRecords: records.length,
|
|
223
|
+
toolCallsByOutcome,
|
|
224
|
+
permissionsBySource,
|
|
225
|
+
durationMsP50: percentile(durations, 50),
|
|
226
|
+
durationMsP95: percentile(durations, 95),
|
|
227
|
+
waitMsP50: percentile(waits, 50),
|
|
228
|
+
waitMsP95: percentile(waits, 95),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AuditLog, auditDir, summarizeAudit } from "./audit.js";
|
|
4
|
+
import { loadConfig } from "../config/config.js";
|
|
5
|
+
import { retentionDaysFor, DEFAULT_RETENTION_DAYS } from "../config/retention.js";
|
|
6
|
+
export const AUDIT_USAGE = `kritya audit — inspect the local, tamper-evident audit log
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
kritya audit --list list recent audit log files, newest first
|
|
10
|
+
kritya audit --verify [file] check a log's hash chain is intact
|
|
11
|
+
(defaults to the most recent log)
|
|
12
|
+
kritya audit --show [file] print a log's records, one JSON line each
|
|
13
|
+
(defaults to the most recent log)
|
|
14
|
+
kritya audit --summary [file] counts and latency percentiles for a log
|
|
15
|
+
(defaults to the most recent log)
|
|
16
|
+
kritya audit --prune [days] delete audit logs older than [days]
|
|
17
|
+
(defaults to your configured retention,
|
|
18
|
+
currently ${DEFAULT_RETENTION_DAYS} unless set in config.json)
|
|
19
|
+
|
|
20
|
+
Audit logs live under ~/.kritya/audit/<session>.audit.jsonl — see the
|
|
21
|
+
"Audit log & telemetry" section of the README for what gets recorded, and for
|
|
22
|
+
how to set retentionDays / audit / otel in ~/.kritya/config.json.`;
|
|
23
|
+
function listAuditFiles() {
|
|
24
|
+
let names;
|
|
25
|
+
try {
|
|
26
|
+
names = fs.readdirSync(auditDir()).filter((f) => f.endsWith(".audit.jsonl"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return names.map((f) => path.join(auditDir(), f)).sort((a, b) => statMtime(b) - statMtime(a));
|
|
32
|
+
}
|
|
33
|
+
function statMtime(file) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.statSync(file).mtimeMs;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Resolves a --verify/--show argument (bare filename, absolute path, or omitted) to a full path. */
|
|
42
|
+
function resolveFile(arg) {
|
|
43
|
+
if (!arg)
|
|
44
|
+
return listAuditFiles()[0];
|
|
45
|
+
return path.isAbsolute(arg) ? arg : path.join(auditDir(), arg);
|
|
46
|
+
}
|
|
47
|
+
function describeVerify(file) {
|
|
48
|
+
const result = AuditLog.verify(file);
|
|
49
|
+
if (result.ok)
|
|
50
|
+
return `${result.records} record(s), chain intact`;
|
|
51
|
+
if (result.reason === "unreadable")
|
|
52
|
+
return "FAILED — file could not be read";
|
|
53
|
+
return `FAILED — chain broken at record ${result.line}`;
|
|
54
|
+
}
|
|
55
|
+
/** Handles `kritya audit ...`. Returns the process exit code. */
|
|
56
|
+
export function runAuditCli(argv) {
|
|
57
|
+
if (argv.includes("--list")) {
|
|
58
|
+
const files = listAuditFiles();
|
|
59
|
+
if (!files.length) {
|
|
60
|
+
console.log(`No audit logs found under ${auditDir()}`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
console.log(`${path.basename(file)} — ${describeVerify(file)}`);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const verifyIdx = argv.indexOf("--verify");
|
|
69
|
+
if (verifyIdx !== -1) {
|
|
70
|
+
const next = argv[verifyIdx + 1];
|
|
71
|
+
const file = resolveFile(next && !next.startsWith("--") ? next : undefined);
|
|
72
|
+
if (!file) {
|
|
73
|
+
console.error(`No audit log found under ${auditDir()}`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const result = AuditLog.verify(file);
|
|
77
|
+
console.log(`${file}\n${describeVerify(file)}`);
|
|
78
|
+
return result.ok ? 0 : 1;
|
|
79
|
+
}
|
|
80
|
+
const showIdx = argv.indexOf("--show");
|
|
81
|
+
if (showIdx !== -1) {
|
|
82
|
+
const next = argv[showIdx + 1];
|
|
83
|
+
const file = resolveFile(next && !next.startsWith("--") ? next : undefined);
|
|
84
|
+
if (!file) {
|
|
85
|
+
console.error(`No audit log found under ${auditDir()}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
for (const record of AuditLog.readRecords(file)) {
|
|
89
|
+
console.log(JSON.stringify(record));
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const summaryIdx = argv.indexOf("--summary");
|
|
94
|
+
if (summaryIdx !== -1) {
|
|
95
|
+
const next = argv[summaryIdx + 1];
|
|
96
|
+
const file = resolveFile(next && !next.startsWith("--") ? next : undefined);
|
|
97
|
+
if (!file) {
|
|
98
|
+
console.error(`No audit log found under ${auditDir()}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const s = summarizeAudit(AuditLog.readRecords(file));
|
|
102
|
+
console.log(`${file}`);
|
|
103
|
+
console.log(`${s.totalRecords} record(s)`);
|
|
104
|
+
console.log(`Permission decisions by source: ${fmtCounts(s.permissionsBySource)}`);
|
|
105
|
+
console.log(`Tool outcomes: ${fmtCounts(s.toolCallsByOutcome)}`);
|
|
106
|
+
console.log(`Tool latency: p50 ${s.durationMsP50}ms / p95 ${s.durationMsP95}ms`);
|
|
107
|
+
console.log(`Permission wait: p50 ${s.waitMsP50}ms / p95 ${s.waitMsP95}ms`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (argv.includes("--prune")) {
|
|
111
|
+
const idx = argv.indexOf("--prune");
|
|
112
|
+
const next = argv[idx + 1];
|
|
113
|
+
const explicitDays = next && !next.startsWith("--") ? Number(next) : undefined;
|
|
114
|
+
const days = explicitDays !== undefined && Number.isFinite(explicitDays)
|
|
115
|
+
? explicitDays
|
|
116
|
+
: retentionDaysFor(loadConfig());
|
|
117
|
+
if (days <= 0) {
|
|
118
|
+
console.log("Retention is disabled (0 or negative) — nothing pruned. Pass a positive number of days.");
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
const before = listAuditFiles().length;
|
|
122
|
+
AuditLog.cleanupOld(days);
|
|
123
|
+
const after = listAuditFiles().length;
|
|
124
|
+
console.log(`Pruned ${before - after} audit log(s) older than ${days} day(s).`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
console.log(AUDIT_USAGE);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
function fmtCounts(m) {
|
|
131
|
+
return (Object.entries(m)
|
|
132
|
+
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
|
|
133
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
134
|
+
.join(", ") || "(none)");
|
|
135
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
4
|
+
import { debugLog } from "../config/debug.js";
|
|
5
|
+
function readCommandsFrom(dir) {
|
|
6
|
+
let files;
|
|
7
|
+
try {
|
|
8
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const commands = [];
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(path.join(dir, file), "utf8");
|
|
17
|
+
const name = "/" + path.basename(file, ".md");
|
|
18
|
+
const { description, body } = parseCommand(raw);
|
|
19
|
+
commands.push({ name, description, body });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// Skip unreadable command files.
|
|
23
|
+
debugLog(`readCommandsFrom(${path.join(dir, file)})`, err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return commands;
|
|
27
|
+
}
|
|
28
|
+
/** Extract an optional `description:` front-matter-ish first line. */
|
|
29
|
+
function parseCommand(raw) {
|
|
30
|
+
const lines = raw.split("\n");
|
|
31
|
+
const first = lines[0]?.trim() ?? "";
|
|
32
|
+
const m = /^(?:<!--\s*)?description:\s*(.+?)(?:\s*-->)?$/i.exec(first);
|
|
33
|
+
if (m) {
|
|
34
|
+
return { description: m[1].trim(), body: lines.slice(1).join("\n").trim() };
|
|
35
|
+
}
|
|
36
|
+
return { description: "custom command", body: raw.trim() };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Load custom commands; workspace entries override plugin entries, which
|
|
40
|
+
* override global ones, by name. Workspace and plugin commands are
|
|
41
|
+
* attacker-controllable prompts (a plugin can be dropped into a cloned repo
|
|
42
|
+
* just like a .kritya/commands file), so both are only loaded once the
|
|
43
|
+
* workspace has been trusted (see src/trust/trust.ts); global
|
|
44
|
+
* (~/.kritya/commands/) commands are always trusted.
|
|
45
|
+
*/
|
|
46
|
+
export function loadCustomCommands(workspace, trustWorkspace = true, plugins = []) {
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const cmd of readCommandsFrom(path.join(CONFIG_DIR, "commands"))) {
|
|
49
|
+
byName.set(cmd.name, cmd);
|
|
50
|
+
}
|
|
51
|
+
if (trustWorkspace) {
|
|
52
|
+
for (const plugin of plugins) {
|
|
53
|
+
for (const cmd of readCommandsFrom(path.join(plugin.dir, "commands"))) {
|
|
54
|
+
byName.set(cmd.name, { ...cmd, pluginName: plugin.name });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const cmd of readCommandsFrom(path.join(workspace, ".kritya", "commands"))) {
|
|
58
|
+
byName.set(cmd.name, cmd);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
62
|
+
}
|
|
63
|
+
/** Substitute the user's arguments into a command body. */
|
|
64
|
+
export function expandCommand(body, args) {
|
|
65
|
+
if (!/\$ARGUMENTS|\{\{args\}\}/.test(body)) {
|
|
66
|
+
return args ? `${body}\n\n${args}` : body;
|
|
67
|
+
}
|
|
68
|
+
return body.replace(/\$ARGUMENTS|\{\{args\}\}/g, args);
|
|
69
|
+
}
|