kritya 0.8.2-beta → 0.8.4-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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # kritya
2
2
 
3
+ [![CI](https://github.com/GenAICloudDevOps/Kritya/actions/workflows/ci.yml/badge.svg)](https://github.com/GenAICloudDevOps/Kritya/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/kritya/beta)](https://www.npmjs.com/package/kritya)
5
+ [![License](https://img.shields.io/github/license/GenAICloudDevOps/Kritya)](LICENSE)
6
+
3
7
  > ⚠️ **Beta** — APIs, flags, and behavior may still change between releases.
4
8
 
5
9
  Open source, [MIT licensed](LICENSE).
package/dist/engine.js CHANGED
@@ -68,7 +68,7 @@ export async function createEngineSession(dir, opts = {}) {
68
68
  SessionStore.cleanupOldSessions(retentionDays);
69
69
  AuditLog.cleanupOld(retentionDays);
70
70
  cleanupOldTelemetry(retentionDays);
71
- const permissions = new PermissionManager(loadRules(workspace, trustWorkspace));
71
+ const permissions = new PermissionManager(loadRules(workspace, trustWorkspace), workspace);
72
72
  const agent = new Agent(client, () => currentModel, ALL_TOOLS, { workspace, sandboxMode: config.sandboxExec ?? defaultSandboxMode(), trustWorkspace }, permissions, session, []);
73
73
  agent.contextWindow = contextWindowFor(currentModel, config);
74
74
  if (config.maxSteps && config.maxSteps > 0)
package/dist/headless.js CHANGED
@@ -156,7 +156,7 @@ export async function runHeadless(args) {
156
156
  const onElicitation = async () => ({ action: "cancel" });
157
157
  const mcpTools = await loadMcpTools(mergeMcpServers(config.mcpServers, approvedProjectMcp, approvedPluginMcp), { tracer: sessionTracer, audit: sessionAudit, workspace, onSampling, onElicitation });
158
158
  const tools = [...ALL_TOOLS, ...mcpTools];
159
- const permissions = new PermissionManager(loadRules(workspace, trustWorkspace));
159
+ const permissions = new PermissionManager(loadRules(workspace, trustWorkspace), workspace);
160
160
  const agent = new Agent(client, () => model, tools,
161
161
  // No requestElicitation here — no UI to ask through. Leaving it undefined
162
162
  // (rather than an always-cancel stub) lets ask_user report itself as
package/dist/index.js CHANGED
@@ -477,7 +477,7 @@ async function main() {
477
477
  };
478
478
  }
479
479
  async function runReadOnlyAgent(task, signal) {
480
- const sub = new Agent(client, () => modelRef.current, readOnlySubTools, { workspace, sandboxMode, trustWorkspace }, new PermissionManager(), new SessionStore(workspace, true), []);
480
+ const sub = new Agent(client, () => modelRef.current, readOnlySubTools, { workspace, sandboxMode, trustWorkspace }, new PermissionManager([], workspace), new SessionStore(workspace, true), []);
481
481
  sub.maxSteps = 15;
482
482
  sub.audit = sessionAudit;
483
483
  sub.tracer = sessionTracer;
@@ -524,7 +524,7 @@ async function main() {
524
524
  // since there's no one to confirm it and letting it run unattended would
525
525
  // be unsafe even inside an isolated worktree (it still has real shell/
526
526
  // network access).
527
- const sub = new Agent(client, () => modelRef.current, writeSubTools, { workspace: wt.dir, sandboxMode, trustWorkspace }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }), new SessionStore(wt.dir, true), []);
527
+ const sub = new Agent(client, () => modelRef.current, writeSubTools, { workspace: wt.dir, sandboxMode, trustWorkspace }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }, wt.dir), new SessionStore(wt.dir, true), []);
528
528
  sub.maxSteps = 30;
529
529
  sub.audit = sessionAudit;
530
530
  sub.tracer = sessionTracer;
@@ -643,7 +643,7 @@ async function main() {
643
643
  },
644
644
  requestElicitation: onAskUser,
645
645
  spawnAgents,
646
- }, new PermissionManager(loadRules(workspace, trustWorkspace)), session, initialHistory);
646
+ }, new PermissionManager(loadRules(workspace, trustWorkspace), workspace), session, initialHistory);
647
647
  agent.contextWindow = contextWindowFor(modelRef.current, config);
648
648
  if (config.maxSteps && config.maxSteps > 0)
649
649
  agent.maxSteps = config.maxSteps;
@@ -47,6 +47,11 @@ export class LspClient {
47
47
  windowsHide: true,
48
48
  });
49
49
  this.proc = proc;
50
+ // A write can still be in flight when dispose() kills the process —
51
+ // stdin then errors asynchronously (EPIPE), separately from proc's own
52
+ // "error" event below. Unhandled, that's an uncaught exception; on
53
+ // Windows the timing between kill() and pipe teardown makes it common.
54
+ proc.stdin.on("error", () => { });
50
55
  proc.on("error", (err) => {
51
56
  this.markDead(err.code === "ENOENT"
52
57
  ? `'${this.config.command}' is not installed (install: ${this.config.installHint})`
@@ -14,13 +14,25 @@ import { debugLog } from "../config/debug.js";
14
14
  * mismatched callback is an attempted CSRF, not a user-visible error case.
15
15
  */
16
16
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
17
+ function escapeHtml(s) {
18
+ return s
19
+ .replace(/&/g, "&")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
23
+ .replace(/'/g, "&#39;");
24
+ }
17
25
  function page(title, detail) {
18
26
  // Deliberately dependency-free and inline-styled: this renders in the user's
19
27
  // browser, and a login callback should not fetch anything from the network.
20
- return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>
28
+ // `detail` in particular can carry query-string content from the OAuth
29
+ // provider (e.g. error_description), so both fields must be HTML-escaped.
30
+ const safeTitle = escapeHtml(title);
31
+ const safeDetail = escapeHtml(detail);
32
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${safeTitle}</title></head>
21
33
  <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:6rem auto;padding:0 1.5rem;line-height:1.6">
22
- <h1 style="font-size:1.25rem;margin:0 0 .5rem">${title}</h1>
23
- <p style="color:#555;margin:0">${detail}</p>
34
+ <h1 style="font-size:1.25rem;margin:0 0 .5rem">${safeTitle}</h1>
35
+ <p style="color:#555;margin:0">${safeDetail}</p>
24
36
  </body></html>`;
25
37
  }
26
38
  export async function startCallbackServer(state) {
@@ -26,7 +26,8 @@ export class PermissionManager {
26
26
  alwaysAllowed = new Set();
27
27
  allow;
28
28
  deny;
29
- constructor(rules = []) {
29
+ workspace;
30
+ constructor(rules = [], workspace) {
30
31
  if (Array.isArray(rules)) {
31
32
  this.allow = rules;
32
33
  this.deny = [];
@@ -35,17 +36,18 @@ export class PermissionManager {
35
36
  this.allow = rules.allow;
36
37
  this.deny = rules.deny;
37
38
  }
39
+ this.workspace = workspace;
38
40
  }
39
41
  /** True if a deny rule blocks this call; such calls are never prompted, always refused. */
40
42
  isDenied(tool, args = {}) {
41
- return this.deny.some((rule) => matchesRule(rule, tool.name, args));
43
+ return this.deny.some((rule) => matchesRule(rule, tool.name, args, this.workspace));
42
44
  }
43
45
  needsPrompt(tool, args = {}) {
44
46
  if (!tool.requiresPermission)
45
47
  return false;
46
48
  if (this.alwaysAllowed.has(alwaysAllowKey(tool.name, args)))
47
49
  return false;
48
- return !this.allow.some((rule) => matchesRule(rule, tool.name, args));
50
+ return !this.allow.some((rule) => matchesRule(rule, tool.name, args, this.workspace));
49
51
  }
50
52
  record(toolName, decision, args = {}) {
51
53
  if (decision === "always")
@@ -25,11 +25,24 @@ export function loadRules(workspace, trustWorkspace = true) {
25
25
  return { allow, deny };
26
26
  }
27
27
  const RULE_RE = /^([a-z_]+)(?:\((.*)\))?$/;
28
- /** The string a pattern is matched against for a given tool. */
29
- function subjectFor(toolName, args) {
28
+ /**
29
+ * The string a pattern is matched against for a given tool.
30
+ *
31
+ * File-tool paths are resolved against `workspace` (when given) before
32
+ * matching, the same way {@link resolveSafe} normalizes them: "./.env" and
33
+ * "sub/../.env" both become ".env". Without this, a rule like
34
+ * `write_file(.env*)` only matches the exact literal string the model
35
+ * happened to pass, and a differently-spelled equivalent path slips past it.
36
+ */
37
+ function subjectFor(toolName, args, workspace) {
30
38
  if (toolName === "shell")
31
39
  return String(args.command ?? "").trim();
32
- return String(args.path ?? args.pattern ?? "").trim();
40
+ const rawPath = args.path;
41
+ if (workspace && typeof rawPath === "string" && rawPath.trim()) {
42
+ const rel = path.relative(workspace, path.resolve(workspace, rawPath));
43
+ return rel.split(path.sep).join("/");
44
+ }
45
+ return String(rawPath ?? args.pattern ?? "").trim();
33
46
  }
34
47
  /**
35
48
  * Shell metacharacters that chain/substitute commands or redirect I/O
@@ -39,7 +52,7 @@ function subjectFor(toolName, args) {
39
52
  * because the allowed pattern never spelled those characters out.
40
53
  */
41
54
  const SHELL_METACHAR_RE = /&&|\|\||[;|`&\n]|\$\(|\$\{|<|>/;
42
- export function matchesRule(rule, toolName, args) {
55
+ export function matchesRule(rule, toolName, args, workspace) {
43
56
  const m = RULE_RE.exec(rule.trim());
44
57
  if (!m)
45
58
  return false;
@@ -49,7 +62,7 @@ export function matchesRule(rule, toolName, args) {
49
62
  if (pattern === undefined)
50
63
  return true;
51
64
  const trimmedPattern = pattern.trim();
52
- const subject = subjectFor(toolName, args);
65
+ const subject = subjectFor(toolName, args, workspace);
53
66
  // A wildcard shell(...) rule (e.g. shell(git *)) is only meant to allow one
54
67
  // command, not an arbitrary chain appended after it. If the actual command
55
68
  // contains shell metacharacters that the allowed pattern itself didn't
@@ -123,23 +123,26 @@ async function readBodyCapped(res, maxBytes) {
123
123
  }
124
124
  /** Collapse an HTML document down to readable plain text (best-effort, no deps). */
125
125
  function htmlToText(html) {
126
- return html
127
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
128
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
129
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
126
+ return (html
127
+ .replace(/<script[\s\S]*?(<\/script\s*>|$)/gi, " ")
128
+ .replace(/<style[\s\S]*?(<\/style\s*>|$)/gi, " ")
129
+ .replace(/<noscript[\s\S]*?(<\/noscript\s*>|$)/gi, " ")
130
130
  .replace(/<!--[\s\S]*?-->/g, " ")
131
131
  .replace(/<\/(p|div|li|tr|h[1-6]|section|article|br)>/gi, "\n")
132
132
  .replace(/<br\s*\/?>/gi, "\n")
133
133
  .replace(/<[^>]+>/g, " ")
134
134
  .replace(/&nbsp;/gi, " ")
135
- .replace(/&amp;/gi, "&")
136
135
  .replace(/&lt;/gi, "<")
137
136
  .replace(/&gt;/gi, ">")
138
137
  .replace(/&quot;/gi, '"')
139
138
  .replace(/&#39;/gi, "'")
139
+ // &amp; must decode last: an already-escaped "&amp;lt;" is meant to render as
140
+ // the literal text "&lt;", not as "<" — decoding &amp; first would collapse
141
+ // it two levels and let doubly-encoded markup smuggle a live "<" through.
142
+ .replace(/&amp;/gi, "&")
140
143
  .replace(/[ \t]+/g, " ")
141
144
  .replace(/\n{3,}/g, "\n\n")
142
- .trim();
145
+ .trim());
143
146
  }
144
147
  /**
145
148
  * Fetch one URL and return its text content. HTML is reduced to plain text;
@@ -36,6 +36,13 @@ const NAMED_PATTERNS = [
36
36
  kind: "Private key block",
37
37
  re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
38
38
  },
39
+ { kind: "npm access token", re: /\bnpm_[A-Za-z0-9]{36}\b/g },
40
+ { kind: "PyPI upload token", re: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}\b/g },
41
+ { kind: "Azure Storage Account key", re: /\b[A-Za-z0-9+/]{86}==(?![A-Za-z0-9+/=])/g },
42
+ {
43
+ kind: "GCP service account key",
44
+ re: /\b[a-z0-9-]+@[a-z0-9-]+\.iam\.gserviceaccount\.com\b/gi,
45
+ },
39
46
  ];
40
47
  // Generic "KEY = <opaque token>" assignments, gated by an entropy check so
41
48
  // ordinary identifiers/URLs/sentences don't trip it.
@@ -149,10 +149,30 @@ function readGatedContent(workspace) {
149
149
  return null;
150
150
  return { allow, hooks, env, commands, mcp, memory, skills };
151
151
  }
152
+ /**
153
+ * Redacts secret values from .env content while preserving variable names, so
154
+ * the trust prompt can show what's being loaded without printing raw
155
+ * credentials to the terminal (visible in scrollback, screen shares,
156
+ * recordings, or session logs).
157
+ */
158
+ function redactEnvContent(env) {
159
+ return env
160
+ .split("\n")
161
+ .map((line) => {
162
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
163
+ if (!match)
164
+ return line;
165
+ const [, key, value] = match;
166
+ return value.length ? `${key}=<redacted>` : line;
167
+ })
168
+ .join("\n");
169
+ }
152
170
  /**
153
171
  * A human-readable rendering of ALL the gated content, for the trust prompt.
154
172
  * The user must be able to see everything they're approving — not just
155
- * settings.json but the .env contents and each custom command file.
173
+ * settings.json but the .env contents and each custom command file. .env
174
+ * values are redacted (see {@link redactEnvContent}) since they're often
175
+ * secrets that shouldn't be printed to the terminal.
156
176
  */
157
177
  export function describeGatedContent(workspace) {
158
178
  const { allow, hooks } = readSettingsGatedContent(workspace);
@@ -163,7 +183,7 @@ export function describeGatedContent(workspace) {
163
183
  sections.push(`.kritya/settings.json (allow rules / hooks):\n${JSON.stringify({ allow, hooks }, null, 2)}`);
164
184
  }
165
185
  if (env !== undefined) {
166
- sections.push(`.env (loaded into the environment of every command):\n${env.trimEnd()}`);
186
+ sections.push(`.env (loaded into the environment of every command — values redacted):\n${redactEnvContent(env.trimEnd())}`);
167
187
  }
168
188
  if (commands) {
169
189
  const list = Object.entries(commands)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.2-beta",
3
+ "version": "0.8.4-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,7 +62,7 @@
62
62
  "@types/node": "^26.2.0",
63
63
  "@types/react": "^19.2.18",
64
64
  "c8": "^12.0.0",
65
- "electron": "^43.3.0",
65
+ "electron": "^43.4.1",
66
66
  "electron-builder": "^26.15.3",
67
67
  "eslint": "^10.8.1",
68
68
  "globals": "^17.11.0",