@yagni-app/code-staging 0.3.0-staging.1071.1 → 0.3.0-staging.1073.1

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.
@@ -72,13 +72,23 @@ export interface FetchCatalogOptions {
72
72
  getToken: () => string | undefined;
73
73
  fetchImpl?: typeof fetch;
74
74
  }
75
+ /** The startup catalog response from GET /api/yagni-code/models. */
76
+ export interface CatalogResult {
77
+ models: ModelEntry[];
78
+ /**
79
+ * Per-workspace Guardian kill switch (yagni_code.guardian). Fail-safe:
80
+ * only an explicit `false` from the backend disables the Guardian — a
81
+ * missing field (older backend) reads as enabled.
82
+ */
83
+ guardianEnabled: boolean;
84
+ }
75
85
  /**
76
86
  * Fetch the YAGNI model catalog at startup.
77
87
  *
78
88
  * @throws an actionable Error (mentioning `yagni login`) on any non-2xx
79
89
  * response so the launcher can surface a clear re-authentication prompt.
80
90
  */
81
- export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<ModelEntry[]>;
91
+ export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<CatalogResult>;
82
92
  /** The startup company brief returned by GET /api/yagni-code/context. */
83
93
  export interface ContextBrief {
84
94
  brief: string;
@@ -85,7 +85,7 @@ export async function fetchCatalog(opts) {
85
85
  throw new Error(`Failed to fetch YAGNI model catalog (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`);
86
86
  }
87
87
  const data = (await res.json());
88
- return data.models;
88
+ return { models: data.models, guardianEnabled: data.guardianEnabled !== false };
89
89
  }
90
90
  /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
91
91
  const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
@@ -39,6 +39,14 @@ export interface PrefixRule {
39
39
  pattern: (string | string[])[];
40
40
  decision: ExecDecision;
41
41
  justification: string;
42
+ /**
43
+ * Escape hatch for allow rules whose command has a mutating flag: if any
44
+ * token AFTER the matched prefix equals one of these (or, for entries ending
45
+ * in "*", starts with the part before the star), the rule does NOT match and
46
+ * evaluation falls through to later rules (usually landing in the prompt
47
+ * band). Example: sed is read-only except with -i/--in-place.
48
+ */
49
+ unlessTokens?: string[];
42
50
  /** Positive test invocations (validated at load if present). */
43
51
  match?: string[][];
44
52
  /** Negative test invocations (validated at load if present). */
@@ -243,6 +243,17 @@ function matchRule(tokens, rule) {
243
243
  return false;
244
244
  }
245
245
  }
246
+ if (rule.unlessTokens) {
247
+ for (const tok of tokens.slice(rule.pattern.length)) {
248
+ for (const unless of rule.unlessTokens) {
249
+ const matches = unless.endsWith("*")
250
+ ? tok.startsWith(unless.slice(0, -1))
251
+ : tok === unless;
252
+ if (matches)
253
+ return false;
254
+ }
255
+ }
256
+ }
246
257
  return true;
247
258
  }
248
259
  /** Classify a single command segment (no shell constructs). */
@@ -340,11 +351,43 @@ export const DEFAULT_EXEC_POLICY = {
340
351
  { pattern: ["true"], decision: "allow", justification: "no-op success" },
341
352
  { pattern: ["false"], decision: "allow", justification: "no-op failure" },
342
353
  { pattern: ["test"], decision: "allow", justification: "test condition" },
343
- { pattern: ["find", ".", "-name"], decision: "allow", justification: "search for files by name" },
344
- { pattern: ["find", ".", "-type"], decision: "allow", justification: "search for files by type" },
354
+ {
355
+ pattern: ["find"],
356
+ decision: "allow",
357
+ justification: "search for files (read-only without -delete/-exec)",
358
+ unlessTokens: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint*", "-fls"],
359
+ },
345
360
  { pattern: ["grep"], decision: "allow", justification: "search text" },
346
361
  { pattern: ["rg"], decision: "allow", justification: "search text (ripgrep)" },
347
362
  { pattern: ["ag"], decision: "allow", justification: "search text (silver searcher)" },
363
+ { pattern: ["cd"], decision: "allow", justification: "change directory (scoped to this bash invocation)" },
364
+ {
365
+ pattern: ["sed"],
366
+ decision: "allow",
367
+ justification: "stream-edit text to stdout (read-only without -i)",
368
+ unlessTokens: ["-i*", "--in-place*"],
369
+ },
370
+ { pattern: ["awk"], decision: "allow", justification: "text processing to stdout" },
371
+ { pattern: ["sort"], decision: "allow", justification: "sort lines" },
372
+ { pattern: ["uniq"], decision: "allow", justification: "filter duplicate lines" },
373
+ { pattern: ["cut"], decision: "allow", justification: "extract columns" },
374
+ { pattern: ["tr"], decision: "allow", justification: "translate characters" },
375
+ { pattern: ["diff"], decision: "allow", justification: "compare files" },
376
+ { pattern: ["nl"], decision: "allow", justification: "number lines" },
377
+ { pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
378
+ { pattern: ["stat"], decision: "allow", justification: "show file metadata" },
379
+ { pattern: ["file"], decision: "allow", justification: "identify file type" },
380
+ { pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
381
+ { pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
382
+ { pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
383
+ { pattern: ["readlink"], decision: "allow", justification: "resolve a symlink" },
384
+ { pattern: ["tree"], decision: "allow", justification: "list directory tree" },
385
+ { pattern: ["du"], decision: "allow", justification: "show disk usage" },
386
+ { pattern: ["df"], decision: "allow", justification: "show filesystem usage" },
387
+ { pattern: ["date"], decision: "allow", justification: "show date/time" },
388
+ { pattern: ["printf"], decision: "allow", justification: "print formatted text" },
389
+ { pattern: ["whoami"], decision: "allow", justification: "show current user" },
390
+ { pattern: ["uname"], decision: "allow", justification: "show system info" },
348
391
  { pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
349
392
  { pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
350
393
  { pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
@@ -353,6 +396,17 @@ export const DEFAULT_EXEC_POLICY = {
353
396
  { pattern: ["git", "remote"], decision: "allow", justification: "list remotes" },
354
397
  { pattern: ["git", "rev-parse"], decision: "allow", justification: "resolve git refs" },
355
398
  { pattern: ["git", "worktree", "list"], decision: "allow", justification: "list worktrees" },
399
+ { pattern: ["git", "blame"], decision: "allow", justification: "show line authorship" },
400
+ { pattern: ["git", "grep"], decision: "allow", justification: "search tracked files" },
401
+ { pattern: ["git", "ls-files"], decision: "allow", justification: "list tracked files" },
402
+ { pattern: ["git", "describe"], decision: "allow", justification: "describe a commit" },
403
+ { pattern: ["git", "shortlog"], decision: "allow", justification: "summarize commit log" },
404
+ { pattern: ["git", "stash", "list"], decision: "allow", justification: "list stashes" },
405
+ { pattern: ["gh", "pr", ["view", "list", "diff", "checks", "status"]], decision: "allow", justification: "read pull request data" },
406
+ { pattern: ["gh", "issue", ["view", "list", "status"]], decision: "allow", justification: "read issue data" },
407
+ { pattern: ["gh", "run", ["view", "list"]], decision: "allow", justification: "read workflow run data" },
408
+ { pattern: ["gh", "repo", "view"], decision: "allow", justification: "read repository data" },
409
+ { pattern: ["gh", "search"], decision: "allow", justification: "search GitHub" },
356
410
  { pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
357
411
  { pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
358
412
  { pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
@@ -37,6 +37,12 @@ export interface GuardianLimits {
37
37
  timeoutMs: number;
38
38
  }
39
39
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
40
+ /**
41
+ * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
42
+ * overrides the session review cap; anything non-numeric or < 1 falls back to
43
+ * the default (a bad value must never zero out the cap and lock the session).
44
+ */
45
+ export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
40
46
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
41
47
  export declare const GUARDIAN_MODEL_TIER = "efficient";
42
48
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
@@ -25,6 +25,17 @@ export const DEFAULT_GUARDIAN_LIMITS = {
25
25
  maxConsecutiveDenials: 3,
26
26
  timeoutMs: 15_000,
27
27
  };
28
+ /**
29
+ * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
30
+ * overrides the session review cap; anything non-numeric or < 1 falls back to
31
+ * the default (a bad value must never zero out the cap and lock the session).
32
+ */
33
+ export function resolveGuardianLimits(env = process.env) {
34
+ const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
35
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
36
+ const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
37
+ return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
38
+ }
28
39
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
29
40
  export const GUARDIAN_MODEL_TIER = "efficient";
30
41
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
@@ -4,7 +4,7 @@ import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
5
5
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
6
  import { type TokenProvider } from "./tokenProvider.js";
7
- import { type ContextBrief, type ModelEntry } from "./config.js";
7
+ import { type CatalogResult, type ContextBrief } from "./config.js";
8
8
  /**
9
9
  * YAGNI Code extension entry point.
10
10
  *
@@ -31,7 +31,7 @@ export interface RegisterYagniDeps {
31
31
  baseUrl: string;
32
32
  getToken: () => string | undefined;
33
33
  fetchImpl?: typeof fetch;
34
- }) => Promise<ModelEntry[]>;
34
+ }) => Promise<CatalogResult>;
35
35
  fetchContextBrief?: (opts: {
36
36
  baseUrl: string;
37
37
  getToken: () => string | undefined;
@@ -106,7 +106,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
106
106
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
107
107
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
108
108
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
109
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
109
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
110
110
  export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
111
111
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
112
112
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
@@ -124,7 +124,7 @@ export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutco
124
124
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
125
125
  export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
126
126
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
127
- export type { FetchCatalogOptions, ModelEntry } from "./config.js";
127
+ export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
128
128
  export { buildYagniProvider } from "./provider.js";
129
129
  export { registerGoCommand } from "./pipeline/goCommand.js";
130
130
  export type { RegisterGoDeps } from "./pipeline/goCommand.js";
@@ -2,7 +2,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
5
- import { DEFAULT_GUARDIAN_LIMITS, formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, reviewCommand } from "./guardian.js";
5
+ import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
6
6
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
7
7
  import { makeAskYagniTool } from "./askYagniTool.js";
8
8
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
@@ -127,7 +127,7 @@ export async function registerYagni(pi, deps = {}) {
127
127
  // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
128
128
  // before onResponse is reached), so message_end is the only seam.
129
129
  let lastAuthRecovery = null;
130
- const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
130
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
131
131
  // Lock the interactive session to the `advanced` tier only. The backend
132
132
  // catalog returns all tiers, but only `advanced` is registered with the
133
133
  // `yagni` provider, so /model and Ctrl+P show a single entry. Child
@@ -236,12 +236,19 @@ export async function registerYagni(pi, deps = {}) {
236
236
  // holds them, review mode confirms them.
237
237
  const modeHolder = createModeHolder();
238
238
  const guardianState = makeGuardianState();
239
- const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" || env.YAGNI_DISABLE_GUARDIAN === "true";
239
+ // Disabled by the local env override OR the workspace kill switch
240
+ // (yagni_code.guardian, read from the catalog response at launch). The env
241
+ // var wins for a single developer's debugging; the flag turns it off for
242
+ // every session in the workspace.
243
+ const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
244
+ env.YAGNI_DISABLE_GUARDIAN === "true" ||
245
+ !workspaceGuardianEnabled;
240
246
  const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
247
+ const guardianLimits = resolveGuardianLimits(env);
241
248
  registerPermissionGate(pi, {
242
249
  modeHolder,
243
250
  guardianState,
244
- guardianLimits: DEFAULT_GUARDIAN_LIMITS,
251
+ guardianLimits,
245
252
  guardianTier,
246
253
  guardianDisabled,
247
254
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
@@ -292,7 +299,7 @@ export async function registerYagni(pi, deps = {}) {
292
299
  // spend as an ordinary caller row.
293
300
  advisorSubtotal: () => {
294
301
  const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
295
- const guardian = formatGuardianSubtotal(guardianState.read(), DEFAULT_GUARDIAN_LIMITS);
302
+ const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
296
303
  return [advisor, guardian].filter(Boolean).join(" ");
297
304
  },
298
305
  fetchHeadroom: async (signal) => {
@@ -674,7 +681,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
674
681
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
675
682
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
676
683
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
677
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
684
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
678
685
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
679
686
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
680
687
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1071.1",
3
+ "version": "0.3.0-staging.1073.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "9a7610bc34b0cbed0b665ea246880ea23470db78"
41
+ "yagniSourceSha": "4f5ad5c10b5601f93f1802ae9cf541a4670f7963"
42
42
  }