kritya 0.8.3-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/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;
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.3-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",