kritya 0.8.61-beta → 0.8.63-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.
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { scanForSecrets } from "../tools/secretScan.js";
3
4
  const MEMORY_FILE = "KRITYA.md";
4
5
  const AUTO_HEADING = "## Learned by kritya (auto-updated; edit or delete freely)";
5
6
  const MAX_FACTS = 20;
@@ -132,6 +133,14 @@ export function mergeProjectMemory(workspace, newFacts) {
132
133
  for (const fact of newFacts) {
133
134
  if (seen.has(fact.toLowerCase()))
134
135
  continue;
136
+ // Write-time secret guard: facts are extracted from session transcripts,
137
+ // which can include credentials the model saw in tool output, and
138
+ // KRITYA.md is a plain workspace file that is read back into future
139
+ // prompts. A fact that looks like it contains a real secret is dropped
140
+ // rather than persisted — silently, since there is no user-facing write
141
+ // to block here, and the fact's value is the secret itself.
142
+ if (scanForSecrets(fact).length > 0)
143
+ continue;
135
144
  seen.add(fact.toLowerCase());
136
145
  added.push(fact);
137
146
  }
@@ -3,6 +3,7 @@ import { classifyLaterExec, classifyLaterExecInShell, recordLaterExec, workspace
3
3
  import { acknowledgeUnsandboxedFallback, sandboxAvailable, sandboxFallbackWarning, } from "../shell/sandbox.js";
4
4
  import { isPlanningDocWrite, loadProjectState } from "./workflow.js";
5
5
  import { redactSecrets } from "../tools/secretScan.js";
6
+ import { classifyReconCall, ReconTripwire, RECON_TRIP_THRESHOLD, RECON_WINDOW_CALLS, } from "../permissions/recon.js";
6
7
  /** How much tool output to hand the UI (it shows a preview and expands on toggle). */
7
8
  const PREVIEW_CHARS = 4000;
8
9
  /**
@@ -91,6 +92,12 @@ function fenceExternal(output) {
91
92
  export class ToolExecutor {
92
93
  tools;
93
94
  host;
95
+ /**
96
+ * Per-session recon-pattern score (see permissions/recon.ts). One
97
+ * executor serves one agent session, so the tripwire's state naturally
98
+ * spans exactly that session's calls and resets with it.
99
+ */
100
+ tripwire = new ReconTripwire();
94
101
  constructor(tools, host) {
95
102
  this.tools = tools;
96
103
  this.host = host;
@@ -211,6 +218,33 @@ export class ToolExecutor {
211
218
  };
212
219
  if (host.kill.active)
213
220
  return killBlocked();
221
+ // Recon-pattern tripwire. The gates below (and the tools' own checks)
222
+ // judge calls one at a time; this is the one place that sees the
223
+ // sequence. Every call is scored, including ones another gate is about
224
+ // to block anyway — an attempt at a credential file is a signal whether
225
+ // or not it succeeds. Recording happens in this synchronous stretch,
226
+ // before any await, so a parallel batch of read-only calls can't race
227
+ // the score. Once tripped, only recon-carrying calls are blocked;
228
+ // ordinary work flows through the normal gates below.
229
+ const recon = this.tripwire.record(classifyReconCall(name, args));
230
+ if (recon.blocked) {
231
+ host.audit?.logPermission({
232
+ tool: name,
233
+ summary,
234
+ verdict: "denied",
235
+ source: "recon-tripwire",
236
+ });
237
+ logToolOutcome("blocked");
238
+ finishSpan("ERROR", "blocked: recon-pattern tripwire");
239
+ handlers.onToolEnd(id, name, summary, "blocked: recon-pattern tripwire", true);
240
+ return (`Stopped by the recon-pattern tripwire: this session's recent calls form a ` +
241
+ `reconnaissance-like pattern (${this.tripwire.tripKinds.join(", ")}) — ${recon.score} ` +
242
+ `points across the last ${RECON_WINDOW_CALLS} calls, over the limit of ` +
243
+ `${RECON_TRIP_THRESHOLD}. This call was blocked, and further reconnaissance-like ` +
244
+ `calls stay blocked for the rest of the session. Ordinary work (reading and ` +
245
+ `editing project files, builds, tests) can continue. If this is legitimate ` +
246
+ `debugging, tell the user what you are doing and why; a new session resets the tripwire.`);
247
+ }
214
248
  // The planning-doc exemption is scoped to the active project's own
215
249
  // docs/<slug>/ folder, so plan mode can persist its artifact without
216
250
  // becoming a way to edit unrelated documentation.
@@ -0,0 +1,162 @@
1
+ import { commandTouchesSensitivePath, isSensitivePath } from "../tools/common.js";
2
+ const WEIGHTS = {
3
+ "credential-probe": 5,
4
+ "env-dump": 4,
5
+ enumeration: 2,
6
+ "system-probe": 2,
7
+ };
8
+ /** Score at or above which the tripwire latches for the rest of the session. */
9
+ export const RECON_TRIP_THRESHOLD = 10;
10
+ /** How many recent calls the rolling score covers. */
11
+ export const RECON_WINDOW_CALLS = 20;
12
+ function signal(kind, detail) {
13
+ return { kind, weight: WEIGHTS[kind], detail };
14
+ }
15
+ /**
16
+ * Whole-environment dumps. Only the bare forms score: `env FOO=1 cmd` runs a
17
+ * command with one variable set and `printenv PATH` reads one variable —
18
+ * both ordinary. Dumping *everything* is the recon move.
19
+ */
20
+ const ENV_DUMP_PATTERNS = [
21
+ { re: /(^|[|;&]\s*)(env|printenv)(\s*$|\s*[|;&])/, detail: "bare env/printenv dump" },
22
+ { re: /\bexport\s+-p\b/, detail: "export -p dump" },
23
+ { re: /\bdeclare\s+-x\b/, detail: "declare -x dump" },
24
+ { re: /\bcompgen\s+-e\b/, detail: "compgen -e env listing" },
25
+ { re: /\/proc\/(\d+|self)\/environ\b/, detail: "reads /proc environ" },
26
+ { re: /\b(dir|ls|gci|Get-ChildItem)\s+env:/i, detail: "PowerShell env: drive listing" },
27
+ ];
28
+ /** Identity and network-shape questions — cheap to ask, classic to ask first. */
29
+ const SYSTEM_PROBE_PATTERNS = [
30
+ { re: /\bwhoami\b/, detail: "whoami" },
31
+ { re: /\bhostname\b/, detail: "hostname" },
32
+ { re: /\buname\b/, detail: "uname" },
33
+ { re: /\bsysteminfo\b/, detail: "systeminfo" },
34
+ { re: /(^|[|;&]\s*)id\s*($|[|;&])/, detail: "bare id" },
35
+ { re: /\bnetstat\b/, detail: "netstat" },
36
+ { re: /\bifconfig\b/, detail: "ifconfig" },
37
+ { re: /\bipconfig\b/, detail: "ipconfig" },
38
+ { re: /\bip\s+(addr|address|link|route)\b/, detail: "ip addr/link/route" },
39
+ { re: /\barp\s+-a\b/, detail: "arp -a" },
40
+ { re: /\bps\s+(aux|ef|-ef|-aux)\b/, detail: "full process listing" },
41
+ ];
42
+ /**
43
+ * Listing the places a person keeps things, rather than the project: home,
44
+ * the filesystem root, /etc, other users' folders. A listing of the
45
+ * workspace itself (relative paths, /tmp, project dirs) scores nothing.
46
+ */
47
+ const ENUMERATION_PATTERNS = [
48
+ {
49
+ re: /(?:^|[|;&]\s*)(?:ls|dir|find|tree)\b[^|;&]*?\s(?:~|\$HOME|\/etc|\/home|[A-Za-z]:\\Users)(?=[\s|;&]|$)/,
50
+ detail: "lists home/system directories",
51
+ },
52
+ {
53
+ re: /(?:^|[|;&]\s*)(?:ls|dir|find|tree)\b[^|;&]*?\s\/(?=[\s|;&]|$)/,
54
+ detail: "lists the filesystem root",
55
+ },
56
+ ];
57
+ /** Tools whose path/pattern arguments can aim at a credential file or a system directory. */
58
+ const PATH_ARG_TOOLS = new Set([
59
+ "read_file",
60
+ "read_document",
61
+ "read_notebook",
62
+ "edit_file",
63
+ "write_file",
64
+ "grep",
65
+ "glob",
66
+ "list_dir",
67
+ ]);
68
+ /**
69
+ * Classify one tool call into recon signals — at most one signal per kind,
70
+ * so a single busy command can't trip the wire on repetition alone; it
71
+ * takes either one heavy call (a credential probe plus a dump) or a genuine
72
+ * sequence across calls.
73
+ */
74
+ export function classifyReconCall(toolName, args) {
75
+ const byKind = new Map();
76
+ const add = (s) => {
77
+ if (!byKind.has(s.kind))
78
+ byKind.set(s.kind, s);
79
+ };
80
+ if (toolName === "shell") {
81
+ const command = String(args.command ?? "");
82
+ if (command) {
83
+ const sensitive = commandTouchesSensitivePath(command);
84
+ if (sensitive)
85
+ add(signal("credential-probe", `command references "${sensitive}"`));
86
+ for (const { re, detail } of ENV_DUMP_PATTERNS) {
87
+ if (re.test(command)) {
88
+ add(signal("env-dump", detail));
89
+ break;
90
+ }
91
+ }
92
+ for (const { re, detail } of ENUMERATION_PATTERNS) {
93
+ if (re.test(command)) {
94
+ add(signal("enumeration", detail));
95
+ break;
96
+ }
97
+ }
98
+ for (const { re, detail } of SYSTEM_PROBE_PATTERNS) {
99
+ if (re.test(command)) {
100
+ add(signal("system-probe", detail));
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ else if (PATH_ARG_TOOLS.has(toolName)) {
107
+ for (const key of ["path", "pattern"]) {
108
+ const value = args[key];
109
+ if (typeof value !== "string" || !value)
110
+ continue;
111
+ if (isSensitivePath(value)) {
112
+ add(signal("credential-probe", `${toolName} targets "${value}"`));
113
+ }
114
+ if ((toolName === "list_dir" || toolName === "glob") &&
115
+ /^(~|\$HOME|\/|\/etc|\/home)([\\/]|$)/.test(value)) {
116
+ add(signal("enumeration", `${toolName} targets "${value}"`));
117
+ }
118
+ }
119
+ }
120
+ return [...byKind.values()];
121
+ }
122
+ /**
123
+ * The rolling score and the latch. One instance per agent session (the
124
+ * tool executor owns it). Blocked calls are recorded too — the executor
125
+ * records before it blocks — so continued probing keeps the score pinned
126
+ * above the threshold instead of decaying while the caller retries.
127
+ */
128
+ export class ReconTripwire {
129
+ window = [];
130
+ latchedKinds = null;
131
+ get tripped() {
132
+ return this.latchedKinds !== null;
133
+ }
134
+ /** The signal kinds that contributed to the trip, for the block message. */
135
+ get tripKinds() {
136
+ return this.latchedKinds ?? [];
137
+ }
138
+ windowScore() {
139
+ return this.window.reduce((acc, w) => acc + w.score, 0);
140
+ }
141
+ /**
142
+ * Record one call's signals. Returns whether this call must be blocked:
143
+ * the call that pushes the score to the threshold, and — once latched —
144
+ * every later call that carries any recon signal. Signal-free calls are
145
+ * never blocked, tripped or not.
146
+ */
147
+ record(signals) {
148
+ const score = signals.reduce((acc, s) => acc + s.weight, 0);
149
+ this.window.push({ score, kinds: signals.map((s) => s.kind) });
150
+ if (this.window.length > RECON_WINDOW_CALLS)
151
+ this.window.shift();
152
+ const total = this.windowScore();
153
+ if (this.latchedKinds) {
154
+ return { blocked: signals.length > 0, score: total };
155
+ }
156
+ if (total >= RECON_TRIP_THRESHOLD) {
157
+ this.latchedKinds = [...new Set(this.window.flatMap((w) => w.kinds))];
158
+ return { blocked: signals.length > 0, score: total };
159
+ }
160
+ return { blocked: false, score: total };
161
+ }
162
+ }
@@ -45,7 +45,7 @@ const SENSITIVE_PATH_PATTERNS = [
45
45
  /\.p12$/i,
46
46
  /\.pfx$/i,
47
47
  ];
48
- function isSensitivePath(relPath) {
48
+ export function isSensitivePath(relPath) {
49
49
  return SENSITIVE_PATH_PATTERNS.some((re) => re.test(relPath));
50
50
  }
51
51
  /** Splits a shell command into path-like tokens, stripping quotes/metacharacters. */
@@ -6,6 +6,7 @@ import { readDocx, writeDocx } from "./document/docx.js";
6
6
  import { readXlsx, writeXlsx, editXlsx } from "./document/xlsx.js";
7
7
  import { readPptx, writePptx } from "./document/pptx.js";
8
8
  import { readPdf, writePdf, editPdf } from "./document/pdf.js";
9
+ import { formatSecretWarning, scanForSecrets } from "./secretScan.js";
9
10
  const READABLE_EXTENSIONS = [".docx", ".xlsx", ".pptx", ".pdf"];
10
11
  /**
11
12
  * Bound the raw file read before it's dispatched to any format-specific
@@ -142,6 +143,14 @@ export const writeDocumentTool = {
142
143
  const abs = resolveSafe(ctx.workspace, relPath);
143
144
  const ext = path.extname(abs).toLowerCase();
144
145
  const content = extractContent(args);
146
+ // Same write-time secret guard as write_file: the blocks/sheets/slides
147
+ // payload is model-authored text, and a generated .docx/.xlsx is still
148
+ // a file a real key shouldn't be baked into. The payload is structured,
149
+ // so scan its JSON form — every user-visible string is in there.
150
+ const secrets = scanForSecrets(JSON.stringify(content));
151
+ if (secrets.length > 0) {
152
+ throw new Error(formatSecretWarning(secrets, relPath));
153
+ }
145
154
  let buf;
146
155
  switch (ext) {
147
156
  case ".docx":
@@ -232,6 +241,16 @@ export const editSpreadsheetTool = {
232
241
  const edits = (args.edits ?? []);
233
242
  if (edits.length === 0)
234
243
  throw new Error("edit_spreadsheet: `edits` must be non-empty");
244
+ // Write-time secret guard, same as write_file: a string cell value is
245
+ // model-authored content being persisted into the workbook.
246
+ for (const edit of edits) {
247
+ if (typeof edit.value !== "string")
248
+ continue;
249
+ const secrets = scanForSecrets(edit.value);
250
+ if (secrets.length > 0) {
251
+ throw new Error(formatSecretWarning(secrets, relPath));
252
+ }
253
+ }
235
254
  const buf = await fs.readFile(abs);
236
255
  const { buf: out, applied } = await editXlsx(buf, edits);
237
256
  ctx.undo?.snapshot(abs, relPath);
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import { writeFileAtomic } from "../atomicWrite.js";
3
3
  import { resolveSafe, truncateResult } from "./common.js";
4
+ import { formatSecretWarning, scanForSecrets } from "./secretScan.js";
4
5
  /** nbformat stores text as either a single string or an array of lines. */
5
6
  function joinSource(source) {
6
7
  if (Array.isArray(source))
@@ -170,6 +171,15 @@ export const editNotebookTool = {
170
171
  }
171
172
  const nb = await loadNotebook(abs);
172
173
  const cells = nb.cells;
174
+ // Same write-time secret guard as write_file/edit_file: cell source is
175
+ // model-authored content persisted to disk, and a notebook is an
176
+ // ordinary file a key can hide in just as easily as a .ts file.
177
+ if (op !== "delete" && args.source !== undefined) {
178
+ const secrets = scanForSecrets(String(args.source));
179
+ if (secrets.length > 0) {
180
+ throw new Error(formatSecretWarning(secrets, relPath));
181
+ }
182
+ }
173
183
  switch (op) {
174
184
  case "replace": {
175
185
  if (index >= cells.length) {
@@ -148,3 +148,31 @@ export function formatSecretWarning(matches, path) {
148
148
  `match common key formats or lower its entropy — for example, using an obvious placeholder ` +
149
149
  `like "sk-ant-EXAMPLE...".`);
150
150
  }
151
+ /**
152
+ * Shell commands are the one write path the file tools can't see: a command
153
+ * like `echo '<key>' > notes.txt` or a heredoc puts a literal secret on disk
154
+ * without going through write_file/edit_file. The command string itself is
155
+ * the only place to catch that — but a secret merely *appearing* in a
156
+ * command (a curl Authorization header, say) doesn't put it in a file, so
157
+ * this only fires when the command also has a file-writing construct: an
158
+ * output redirect to a real target (not `>&` fd duplication or a `>=`
159
+ * comparison), `tee`, or `dd of=`. Heredocs are covered through the
160
+ * redirect they almost always pair with (`cat > f <<EOF`); a heredoc fed
161
+ * to an interpreter that then writes the secret itself is out of reach for
162
+ * a string scan and remains a known gap, like novel key formats.
163
+ */
164
+ const SHELL_WRITE_RE = />{1,2}\s*(?![&=>])|\btee\b|\bdd\b[^|;&]*\bof=/;
165
+ export function scanCommandForSecretWrite(command) {
166
+ if (!SHELL_WRITE_RE.test(command))
167
+ return [];
168
+ return scanForSecrets(command);
169
+ }
170
+ export function formatShellSecretWarning(matches) {
171
+ const lines = matches.map((m) => ` - ${m.kind}: ${m.snippet}`);
172
+ return (`Blocked shell command: it writes to a file and contains what looks like real secret(s):\n` +
173
+ lines.join("\n") +
174
+ `\n\nEmbedding a literal secret in a shell command writes it to disk and shell history. ` +
175
+ `Reference the value from the environment instead (e.g. $API_KEY), or if this is ` +
176
+ `genuinely not a secret (e.g. a test fixture), rewrite it so it doesn't match common ` +
177
+ `key formats — for example, using an obvious placeholder like "sk-ant-EXAMPLE...".`);
178
+ }
@@ -4,7 +4,7 @@ import { gitDiffStat } from "../git/git.js";
4
4
  import { backgroundManager } from "../shell/background.js";
5
5
  import { buildSandboxedCommand, networkBlockedHint, requiresSandbox, sandboxUnavailableReason, shouldSandbox, } from "../shell/sandbox.js";
6
6
  import { commandTouchesSensitivePath, truncateTail } from "./common.js";
7
- import { redactSecrets } from "./secretScan.js";
7
+ import { redactSecrets, scanCommandForSecretWrite, formatShellSecretWarning, } from "./secretScan.js";
8
8
  const DEFAULT_TIMEOUT_S = 120;
9
9
  const MAX_TIMEOUT_S = 600;
10
10
  /** git subcommands that rewrite the working tree, index, or history. */
@@ -63,6 +63,14 @@ export const shellTool = {
63
63
  if (sensitivePath) {
64
64
  throw new Error(`Command references "${sensitivePath}", which looks like a secret file, and is blocked from tool access`);
65
65
  }
66
+ // A command can also *create* the secret leak: a literal key in the
67
+ // command string plus a redirect/tee/dd target writes it to a file
68
+ // without going through the scanned file tools. Block that here, for
69
+ // background commands too — this check sits above the background branch.
70
+ const secretWrite = scanCommandForSecretWrite(command);
71
+ if (secretWrite.length > 0) {
72
+ throw new Error(formatShellSecretWarning(secretWrite));
73
+ }
66
74
  if (args.background) {
67
75
  const { id } = backgroundManager.start(command, ctx.workspace, ctx.sandboxMode);
68
76
  // Echoing the command back can leak a credential embedded in it (e.g. a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.61-beta",
3
+ "version": "0.8.63-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {