protect-context 0.0.0-stage → 1.1.0

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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Compact pre-agent brief (SPEC §5 before_agent_start) and digest helper.
3
+ * Pure logic — no pi imports.
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import type { Constraint } from "./defaults.ts";
8
+ import type { LoopInfo } from "./loops.ts";
9
+ import { normalizeOneLine, trunc } from "./text.ts";
10
+
11
+ export function sha1Hex(s: string): string {
12
+ return createHash("sha1").update(s, "utf8").digest("hex");
13
+ }
14
+
15
+ export interface BriefInput {
16
+ contract: Constraint[];
17
+ /** Most recent trusted decisions, newest first (already sliced). */
18
+ decisions?: Array<{ type: string; text: string; tags: string[] }>;
19
+ /** Most recent corrections, newest first (already sliced). */
20
+ corrections: Array<{ type: string; text: string; tags: string[] }>;
21
+ loops: LoopInfo[];
22
+ lastEvidence: { id: string; exit: number; cmd: string; ms: number | null } | null;
23
+ }
24
+
25
+ export const BRIEF_MAX_CONTRACT_LINES = 12;
26
+ export const BRIEF_MAX_DECISIONS = 3;
27
+ export const BRIEF_MAX_CORRECTIONS = 3;
28
+ export const BRIEF_MAX_LOOPS = 2;
29
+ /** Command display cap (chars) — matches Python's cmd[:60] discipline. */
30
+ export const CMD_DISPLAY_MAX = 60;
31
+
32
+ /** One-line cap for brief snippets: collapse whitespace, then truncate with an
33
+ * ellipsis. Model-supplied text must never bloat the re-injected brief (goal #12). */
34
+ export function truncLine(s: unknown, max = 200): string {
35
+ // Normalize before applying the display ellipsis. The normalizer gets a
36
+ // generous pre-cap so trunc(), rather than an invisible hard slice, marks
37
+ // ordinary overlong text.
38
+ return trunc(normalizeOneLine(s, Math.max(max + 1, 10_000)), max);
39
+ }
40
+
41
+ function isTrustedOwnerConstraint(c: Constraint): boolean {
42
+ return c.source === "owner" || c.source === "user";
43
+ }
44
+
45
+ /**
46
+ * Select the constraints that can enter the frozen 12-line brief cap.
47
+ *
48
+ * Authority is more important than append order: newest explicit owner/user
49
+ * constraints come first, then the frozen seed contract, and only then other
50
+ * (including agent-authored) entries. Consequently an agent entry can never
51
+ * crowd a seed constraint out of the brief. Stable source indexes break ties
52
+ * when the day-only `added` field is identical.
53
+ */
54
+ export function selectBriefConstraints(contract: Constraint[]): Constraint[] {
55
+ const indexed = contract.map((constraint, index) => ({ constraint, index }));
56
+ const owners = indexed
57
+ .filter(({ constraint }) => isTrustedOwnerConstraint(constraint))
58
+ .sort(
59
+ (a, b) =>
60
+ String(b.constraint.added ?? "").localeCompare(String(a.constraint.added ?? "")) || b.index - a.index,
61
+ );
62
+ const seeds = indexed.filter(({ constraint }) => constraint.source === "seed");
63
+ const others = indexed.filter(
64
+ ({ constraint }) => constraint.source !== "seed" && !isTrustedOwnerConstraint(constraint),
65
+ );
66
+ return [...owners, ...seeds, ...others]
67
+ .slice(0, BRIEF_MAX_CONTRACT_LINES)
68
+ .map(({ constraint }) => constraint);
69
+ }
70
+
71
+ function appendJournalSection(
72
+ lines: string[],
73
+ heading: "decisions:" | "corrections:",
74
+ entries: Array<{ text: string; tags: string[] }>,
75
+ cap: number,
76
+ ): void {
77
+ if (entries.length === 0) return;
78
+ lines.push(heading);
79
+ for (const entry of entries.slice(0, cap)) {
80
+ const safeTags = [...new Set((Array.isArray(entry.tags) ? entry.tags : []).map((tag) => truncLine(tag, 40)).filter(Boolean))]
81
+ .slice(0, 8);
82
+ const tags = safeTags.length > 0 ? ` [${safeTags.join(",")}]` : "";
83
+ lines.push(`- ${truncLine(entry.text)}${tags}`);
84
+ }
85
+ }
86
+
87
+ /** Build the compact brief: contract (≤12 lines), ≤3 trusted decisions,
88
+ * ≤3 trusted corrections, ≤2 loops, and last evidence. */
89
+ export function buildBrief(input: BriefInput): string {
90
+ const lines: string[] = [];
91
+
92
+ const contractLines = selectBriefConstraints(input.contract);
93
+ if (contractLines.length > 0) {
94
+ lines.push("contract:");
95
+ for (const c of contractLines) {
96
+ // Provenance: seed constraints are the frozen defaults; anything else is
97
+ // tagged with its source so agent-written constraints are visible as such.
98
+ const source = truncLine(c.source, 40);
99
+ const src = source && source !== "seed" ? ` [${source}]` : "";
100
+ lines.push(`${truncLine(c.id, 80) || "(missing-id)"}${src}: ${truncLine(c.text)}`);
101
+ }
102
+ const hidden = input.contract.length - contractLines.length;
103
+ if (hidden > 0) {
104
+ // Never silently drop owner-added constraints: announce the overflow so
105
+ // the model knows constraints exist beyond the injected 12 (goal #1).
106
+ lines.push(`+${hidden} more constraints hidden — run /guard-contract list`);
107
+ }
108
+ }
109
+
110
+ appendJournalSection(lines, "decisions:", input.decisions ?? [], BRIEF_MAX_DECISIONS);
111
+ appendJournalSection(lines, "corrections:", input.corrections, BRIEF_MAX_CORRECTIONS);
112
+
113
+ if (input.loops.length > 0) {
114
+ lines.push("loops:");
115
+ for (const l of input.loops.slice(0, BRIEF_MAX_LOOPS)) {
116
+ lines.push(`- '${truncLine(l.cmd, CMD_DISPLAY_MAX)}' failed ${l.failures}x`);
117
+ }
118
+ }
119
+
120
+ if (input.lastEvidence) {
121
+ const ms = input.lastEvidence.ms === null ? "" : ` ${(input.lastEvidence.ms / 1000).toFixed(1)}s`;
122
+ lines.push(
123
+ `last verify: ${truncLine(input.lastEvidence.id, 40)} exit=${input.lastEvidence.exit} '${truncLine(input.lastEvidence.cmd, CMD_DISPLAY_MAX)}'${ms}`,
124
+ );
125
+ }
126
+
127
+ return lines.join("\n");
128
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * config.json load/merge (SPEC §2). Pure merge + fs load — no pi imports.
3
+ */
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { DEFAULT_CONFIG, type GuardConfig } from "./defaults.ts";
7
+ import { configPath, readJson, storeDir, withStoreLock, writeJson } from "./store.ts";
8
+
9
+ function isInteger(v: unknown): v is number {
10
+ return typeof v === "number" && Number.isInteger(v);
11
+ }
12
+
13
+ function strArray(v: unknown): string[] | null {
14
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) return null;
15
+ return v as string[];
16
+ }
17
+
18
+ /**
19
+ * Merge a partial config over the frozen defaults. Unknown keys are ignored;
20
+ * wrong-typed values fall back to the default (deterministic, test-pinned).
21
+ */
22
+ export function mergeConfig(partial: Record<string, unknown> | null | undefined): GuardConfig {
23
+ const cfg: GuardConfig = { ...DEFAULT_CONFIG, preflight_tools: [...DEFAULT_CONFIG.preflight_tools] };
24
+ if (!partial || typeof partial !== "object") return cfg;
25
+
26
+ const p = partial as Record<string, unknown>;
27
+ if (isInteger(p.loop_block_after) && p.loop_block_after >= 1) cfg.loop_block_after = p.loop_block_after;
28
+ if (isInteger(p.diff_warn_lines) && p.diff_warn_lines >= 0) cfg.diff_warn_lines = p.diff_warn_lines;
29
+ if (isInteger(p.diff_hard_lines) && p.diff_hard_lines >= 0) cfg.diff_hard_lines = p.diff_hard_lines;
30
+ if (isInteger(p.diff_warn_files) && p.diff_warn_files >= 0) cfg.diff_warn_files = p.diff_warn_files;
31
+ if (isInteger(p.ctx_warn_pct) && p.ctx_warn_pct >= 0 && p.ctx_warn_pct <= 100) cfg.ctx_warn_pct = p.ctx_warn_pct;
32
+ if (p.brief_mode === "changed" || p.brief_mode === "always") cfg.brief_mode = p.brief_mode;
33
+ if (typeof p.risk_confirm === "boolean") cfg.risk_confirm = p.risk_confirm;
34
+ const tools = strArray(p.preflight_tools);
35
+ if (tools) cfg.preflight_tools = [...tools];
36
+ return cfg;
37
+ }
38
+
39
+ export interface LoadedConfig {
40
+ config: GuardConfig;
41
+ path: string;
42
+ existed: boolean;
43
+ }
44
+
45
+ /** Load `.agent-guard/config.json` merged over defaults. Missing file →
46
+ * defaults; extension startup can request an on-disk seed so an extension-only
47
+ * install has the documented configuration surface without first running the
48
+ * Python CLI. Existing corrupt files are never overwritten. */
49
+ function readLoadedConfig(cwd: string): LoadedConfig {
50
+ const path = configPath(cwd);
51
+ const existed = existsSync(path);
52
+ const raw = readJson<Record<string, unknown>>(path);
53
+ const config = mergeConfig(raw ?? undefined);
54
+ return { config, path, existed };
55
+ }
56
+
57
+ export function loadConfig(cwd: string, seedMissing = false): LoadedConfig {
58
+ if (!seedMissing) return readLoadedConfig(cwd);
59
+ // Serialize and re-check the whole missing→seed transition. Without the
60
+ // second read inside the lease, an owner-written config could be replaced
61
+ // by defaults between an optimistic exists() and writeJson().
62
+ return withStoreLock(storeDir(cwd), () => {
63
+ const loaded = readLoadedConfig(cwd);
64
+ if (!loaded.existed) writeJson(loaded.path, loaded.config);
65
+ return loaded;
66
+ });
67
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Contract store CRUD (SPEC §1 #1, §2, §5). No pi imports.
3
+ */
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { DEFAULT_CONTRACT, type Constraint, type Contract } from "./defaults.ts";
7
+ import { contractPath, readJson, storeDir, withStoreLock, writeJson } from "./store.ts";
8
+ import { normalizeOneLine } from "./text.ts";
9
+
10
+ /** Free-text cap (chars): constraint text flows into re-injected briefs (goal #12). */
11
+ export const TEXT_CAP = 500;
12
+ export const CONTRACT_RENDER_MAX = 24;
13
+
14
+ export class ContractIntegrityError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = "ContractIntegrityError";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Load the contract, seeding `.agent-guard/contract.json` from DEFAULT_CONTRACT
23
+ * only when the file does not exist. A corrupt/unreadable file falls back to
24
+ * in-memory defaults WITHOUT writing, so a transient read error or corruption
25
+ * can never silently destroy the user's constraints (mirrors contract.load()
26
+ * on the Python side).
27
+ */
28
+ function cloneDefaultContract(): Contract {
29
+ return JSON.parse(JSON.stringify(DEFAULT_CONTRACT)) as Contract;
30
+ }
31
+
32
+ function isConstraint(value: unknown): value is Constraint {
33
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
34
+ const row = value as Record<string, unknown>;
35
+ return (
36
+ typeof row.id === "string" &&
37
+ typeof row.text === "string" &&
38
+ typeof row.added === "string" &&
39
+ typeof row.source === "string"
40
+ );
41
+ }
42
+
43
+ /** Caller must hold the shared store lease. */
44
+ function loadContractUnlocked(cwd: string, forMutation = false): Contract {
45
+ const file = contractPath(cwd);
46
+ if (!existsSync(file)) {
47
+ const seeded = cloneDefaultContract();
48
+ saveContractUnlocked(cwd, seeded);
49
+ return seeded;
50
+ }
51
+ const existing = readJson<unknown>(file);
52
+ if (existing && typeof existing === "object" && Array.isArray((existing as { constraints?: unknown }).constraints)) {
53
+ const rows = (existing as { constraints: unknown[] }).constraints;
54
+ const constraints = rows.filter(isConstraint);
55
+ if (forMutation && constraints.length !== rows.length) {
56
+ throw new ContractIntegrityError(
57
+ "contract contains malformed rows; repair .agent-guard/contract.json before mutating it",
58
+ );
59
+ }
60
+ // A non-empty list with no valid records is effectively corrupt. Serve
61
+ // frozen seeds in memory, but never overwrite the owner's damaged file.
62
+ if (rows.length > 0 && constraints.length === 0) return cloneDefaultContract();
63
+ return { version: 1, constraints };
64
+ }
65
+ if (forMutation) {
66
+ throw new ContractIntegrityError(
67
+ "contract file is malformed; repair .agent-guard/contract.json before mutating it",
68
+ );
69
+ }
70
+ return cloneDefaultContract();
71
+ }
72
+
73
+ /** Caller must hold the shared store lease. */
74
+ function saveContractUnlocked(cwd: string, contract: Contract): void {
75
+ writeJson(contractPath(cwd), contract);
76
+ }
77
+
78
+ export function loadContract(cwd: string): Contract {
79
+ return withStoreLock(storeDir(cwd), () => loadContractUnlocked(cwd));
80
+ }
81
+
82
+ export function saveContract(cwd: string, contract: Contract): void {
83
+ withStoreLock(storeDir(cwd), () => saveContractUnlocked(cwd, contract));
84
+ }
85
+
86
+ /** Next sequential id, e.g. "13" after the 12 seeded constraints. */
87
+ export function nextConstraintId(constraints: Constraint[]): string {
88
+ let max = 0;
89
+ for (const c of constraints) {
90
+ const m = c.id.match(/^(\d+)(?:-|$)/);
91
+ if (m) max = Math.max(max, Number(m[1]));
92
+ }
93
+ return String(max + 1).padStart(2, "0");
94
+ }
95
+
96
+ export interface AddConstraintResult {
97
+ constraint: Constraint;
98
+ added: boolean;
99
+ total: number;
100
+ }
101
+
102
+ export function addConstraintWithStatus(cwd: string, text: string, source = "agent"): AddConstraintResult {
103
+ const cleanText = normalizeOneLine(text, TEXT_CAP);
104
+ if (cleanText === "") throw new Error("constraint text must not be blank");
105
+ return withStoreLock(storeDir(cwd), () => {
106
+ const contract = loadContractUnlocked(cwd, true);
107
+ const existing = contract.constraints.find((constraint) => normalizeOneLine(constraint.text, TEXT_CAP) === cleanText);
108
+ if (existing) return { constraint: existing, added: false, total: contract.constraints.length };
109
+ const constraint: Constraint = {
110
+ id: nextConstraintId(contract.constraints),
111
+ text: cleanText,
112
+ added: new Date().toISOString().slice(0, 10),
113
+ source: normalizeOneLine(source, 40) || "agent",
114
+ };
115
+ contract.constraints.push(constraint);
116
+ saveContractUnlocked(cwd, contract);
117
+ return { constraint, added: true, total: contract.constraints.length };
118
+ });
119
+ }
120
+
121
+ export function addConstraint(cwd: string, text: string, source = "agent"): Constraint {
122
+ return addConstraintWithStatus(cwd, text, source).constraint;
123
+ }
124
+
125
+ export function removeConstraint(cwd: string, id: string): boolean {
126
+ return withStoreLock(storeDir(cwd), () => {
127
+ const contract = loadContractUnlocked(cwd, true);
128
+ const before = contract.constraints.length;
129
+ contract.constraints = contract.constraints.filter((c) => c.id !== id);
130
+ if (contract.constraints.length === before) return false;
131
+ saveContractUnlocked(cwd, contract);
132
+ return true;
133
+ });
134
+ }
135
+
136
+ /** Bounded compact lines: frozen seeds plus the newest remaining entries. */
137
+ export function contractLines(contract: Contract): string[] {
138
+ const constraints = contract.constraints;
139
+ let visible = constraints;
140
+ if (constraints.length > CONTRACT_RENDER_MAX) {
141
+ const seedIndexes = constraints
142
+ .map((constraint, index) => ({ constraint, index }))
143
+ .filter(({ constraint }) => constraint.source === "seed")
144
+ .map(({ index }) => index)
145
+ .slice(0, CONTRACT_RENDER_MAX);
146
+ const selected = new Set(seedIndexes);
147
+ for (let index = constraints.length - 1; index >= 0 && selected.size < CONTRACT_RENDER_MAX; index -= 1) {
148
+ selected.add(index);
149
+ }
150
+ visible = constraints.filter((_constraint, index) => selected.has(index));
151
+ }
152
+ const lines = visible.map((c) => {
153
+ const id = normalizeOneLine(c.id, 80) || "(missing-id)";
154
+ return `${id}: ${normalizeOneLine(c.text, TEXT_CAP)}`;
155
+ });
156
+ if (visible.length < constraints.length) {
157
+ lines.unshift(`… ${constraints.length - visible.length} older non-seed constraints omitted`);
158
+ }
159
+ return lines;
160
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * guard_contract_check — deterministic checklist over a change summary
3
+ * (SPEC §5). Heuristic keyword coverage per constraint; no LLM. No pi imports.
4
+ */
5
+
6
+ import type { Contract } from "./defaults.ts";
7
+
8
+ /**
9
+ * Keyword coverage per constraint id (lowercased substring matching against
10
+ * the supplied summary).
11
+ */
12
+ export const CONSTRAINT_KEYWORDS: Record<string, string[]> = {
13
+ "01-obey-instructions": ["instruct", "constraint", "owner", "ask instead", "conflict"],
14
+ "02-evidence-before-success": ["evidence", "verif", "ev-", "proof", "test run"],
15
+ "03-stay-in-scope": ["scope", "unrelated", "refactor", "only file", "in scope"],
16
+ "04-orient-first": ["read", "orient", "convention", "familiar", "reviewed"],
17
+ "05-simplest-correct": ["simple", "test", "behavior", "minimal"],
18
+ "06-journal-decisions": ["journal", "decision", "noted", "recorded"],
19
+ "07-log-corrections": ["correction", "tag", "corrected", "feedback"],
20
+ "08-keep-diffs-reviewable": ["diff", "lines", "split", "small", "reviewable"],
21
+ "09-no-identical-retries": ["retry", "failing", "approach", "loop", "changed strategy"],
22
+ "10-preflight-env": ["preflight", "tool", "version", "env", "sandbox"],
23
+ "11-batch-operations": ["batch", "permission", "prompt", "grouped"],
24
+ "12-budget-context": ["budget", "context", "digest", "head", "tail", "targeted"],
25
+ };
26
+
27
+ export interface ChecklistItem {
28
+ id: string;
29
+ addressed: boolean;
30
+ }
31
+
32
+ const CHECKLIST_IDS_MAX = 24;
33
+
34
+ function compactIds(items: ChecklistItem[]): string {
35
+ const ids = items.slice(0, CHECKLIST_IDS_MAX).map((item) => String(item.id).slice(0, 2));
36
+ const omitted = items.length - ids.length;
37
+ return `${ids.length > 0 ? ids.join(" ") : "(none)"}${omitted > 0 ? ` +${omitted} more` : ""}`;
38
+ }
39
+
40
+ /** Mark each constraint addressed/unclear from the summary text. */
41
+ export function checkContractSummary(summary: string, contract: Contract): ChecklistItem[] {
42
+ const hay = summary.toLowerCase();
43
+ return contract.constraints.map((c) => {
44
+ const keywords = CONSTRAINT_KEYWORDS[c.id] ?? [];
45
+ return { id: c.id, addressed: keywords.some((k) => hay.includes(k)) };
46
+ });
47
+ }
48
+
49
+ /** Compact rendering: addressed ids grouped, unclear ids grouped, then a Nudge. */
50
+ export function renderChecklist(items: ChecklistItem[]): string {
51
+ const addressed = items.filter((i) => i.addressed);
52
+ const unclear = items.filter((i) => !i.addressed);
53
+ const lines: string[] = [];
54
+ lines.push(`addressed: ${compactIds(addressed)}`);
55
+ lines.push(`unclear: ${compactIds(unclear)}`);
56
+ lines.push(
57
+ unclear.length > 0
58
+ ? `Nudge: resolve unclear constraints (guard_contract list) or state why they don't apply.`
59
+ : `Nudge: all constraints addressed — verify before claiming success.`,
60
+ );
61
+ return lines.join("\n");
62
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Frozen defaults for protect-context (SPEC.md v1.0 §2 and §6).
3
+ *
4
+ * These must stay byte-identical to `src/protect_context/defaults.py`
5
+ * (both sides are pinned by tests: same 12 constraint ids and exact texts).
6
+ * Do not reword, reorder, or renumber.
7
+ */
8
+
9
+ export interface Constraint {
10
+ id: string;
11
+ text: string;
12
+ added: string;
13
+ source: string;
14
+ }
15
+
16
+ export interface Contract {
17
+ version: 1;
18
+ constraints: Constraint[];
19
+ }
20
+
21
+ export interface GuardConfig {
22
+ loop_block_after: number;
23
+ diff_warn_lines: number;
24
+ diff_hard_lines: number;
25
+ diff_warn_files: number;
26
+ ctx_warn_pct: number;
27
+ brief_mode: "changed" | "always";
28
+ risk_confirm: boolean;
29
+ preflight_tools: string[];
30
+ }
31
+
32
+ /** Seed date = SPEC freeze date. */
33
+ export const SEED_ADDED = "2026-08-28";
34
+
35
+ /** Seed contract — SPEC §6, FROZEN ids and texts. */
36
+ export const DEFAULT_CONTRACT: Contract = {
37
+ version: 1,
38
+ constraints: [
39
+ {
40
+ id: "01-obey-instructions",
41
+ text: "Obey the owner's explicit instructions and stated constraints exactly; when instructions conflict, ask instead of guessing.",
42
+ added: SEED_ADDED,
43
+ source: "seed",
44
+ },
45
+ {
46
+ id: "02-evidence-before-success",
47
+ text: "Never claim success without citing a fresh verification run (evidence id or pasted output) for the exact claim.",
48
+ added: SEED_ADDED,
49
+ source: "seed",
50
+ },
51
+ {
52
+ id: "03-stay-in-scope",
53
+ text: "Change only files the stated task requires; no unrelated refactors, reformatting, or \"improvements\".",
54
+ added: SEED_ADDED,
55
+ source: "seed",
56
+ },
57
+ {
58
+ id: "04-orient-first",
59
+ text: "Before the first edit in an unfamiliar area, read the relevant files and conventions; never code from guesses.",
60
+ added: SEED_ADDED,
61
+ source: "seed",
62
+ },
63
+ {
64
+ id: "05-simplest-correct",
65
+ text: "Prefer the simplest implementation satisfying stated behavior; add or update a test for every behavior change.",
66
+ added: SEED_ADDED,
67
+ source: "seed",
68
+ },
69
+ {
70
+ id: "06-journal-decisions",
71
+ text: "Record durable decisions and constraints in the journal when made; re-read them before resuming work.",
72
+ added: SEED_ADDED,
73
+ source: "seed",
74
+ },
75
+ {
76
+ id: "07-log-corrections",
77
+ text: "When the owner corrects behavior, log a tagged correction; check corrections before similar work.",
78
+ added: SEED_ADDED,
79
+ source: "seed",
80
+ },
81
+ {
82
+ id: "08-keep-diffs-reviewable",
83
+ text: "Keep diffs small: split work into logical steps; treat 400+ changed lines per task as a stop-and-split signal.",
84
+ added: SEED_ADDED,
85
+ source: "seed",
86
+ },
87
+ {
88
+ id: "09-no-identical-retries",
89
+ text: "After 2 failures of the same command, change approach or gather new information; never re-run identical failing commands.",
90
+ added: SEED_ADDED,
91
+ source: "seed",
92
+ },
93
+ {
94
+ id: "10-preflight-env",
95
+ text: "When tools behave unexpectedly, run preflight (tool presence/versions) before blaming code.",
96
+ added: SEED_ADDED,
97
+ source: "seed",
98
+ },
99
+ {
100
+ id: "11-batch-operations",
101
+ text: "Batch related shell operations; prefer commands that avoid per-command permission prompts.",
102
+ added: SEED_ADDED,
103
+ source: "seed",
104
+ },
105
+ {
106
+ id: "12-budget-context",
107
+ text: "Keep tool outputs bounded (head/tail/digest); use targeted reads; avoid re-reading large files.",
108
+ added: SEED_ADDED,
109
+ source: "seed",
110
+ },
111
+ ],
112
+ };
113
+
114
+ /** Default thresholds — SPEC §2, FROZEN. */
115
+ export const DEFAULT_CONFIG: GuardConfig = {
116
+ loop_block_after: 3,
117
+ diff_warn_lines: 400,
118
+ diff_hard_lines: 800,
119
+ diff_warn_files: 10,
120
+ ctx_warn_pct: 70,
121
+ brief_mode: "changed",
122
+ risk_confirm: true,
123
+ preflight_tools: ["git", "python3", "pip", "node", "npm", "rg", "pytest"],
124
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Event records for `.agent-guard/events.jsonl` (SPEC §2, §5).
3
+ * Pure logic + record shapes — no pi imports.
4
+ */
5
+
6
+ export type EventKind = "tool_call" | "tool_result";
7
+
8
+ export interface EventRecord {
9
+ ts: string;
10
+ session: string;
11
+ kind: EventKind;
12
+ tool: string;
13
+ ok: boolean | null;
14
+ ms: number | null;
15
+ out_chars: number | null;
16
+ /** Only set for bash tool_result events; fingerprint-normalized. */
17
+ cmd: string | null;
18
+ exit: number | null;
19
+ }
20
+
21
+ function nowIso(): string {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ export function toolCallEvent(session: string, tool: string): EventRecord {
26
+ return {
27
+ ts: nowIso(),
28
+ session,
29
+ kind: "tool_call",
30
+ tool,
31
+ ok: null,
32
+ ms: null,
33
+ out_chars: null,
34
+ cmd: null,
35
+ exit: null,
36
+ };
37
+ }
38
+
39
+ export function toolResultEvent(
40
+ session: string,
41
+ tool: string,
42
+ opts: { ok: boolean | null; ms: number | null; outChars: number | null; cmd?: string | null; exit?: number | null },
43
+ ): EventRecord {
44
+ return {
45
+ ts: nowIso(),
46
+ session,
47
+ kind: "tool_result",
48
+ tool,
49
+ ok: opts.ok,
50
+ ms: opts.ms,
51
+ out_chars: opts.outChars,
52
+ cmd: opts.cmd ?? null,
53
+ exit: opts.exit ?? null,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The pi bash tool does not expose the exit code in its details; on nonzero
59
+ * exit it throws with "Command exited with code N" appended to the output.
60
+ * Parse it back out; returns null when unavailable (e.g. cancelled).
61
+ */
62
+ export function bashExitFromResult(text: string): number | null {
63
+ const matches = text.matchAll(/Command exited with code (\d+)/g);
64
+ let last: number | null = null;
65
+ for (const m of matches) {
66
+ last = Number(m[1]);
67
+ }
68
+ return last;
69
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Command fingerprinting and risk-pattern matching (SPEC §2, §5).
3
+ * Pure logic — no pi imports, testable via `node --test --experimental-strip-types`.
4
+ */
5
+
6
+ /**
7
+ * Fingerprint normalization (FROZEN): trim, collapse all whitespace runs to
8
+ * single spaces. Nothing else (case is preserved).
9
+ */
10
+ export function normalizeCommand(cmd: string): string {
11
+ return cmd.trim().replace(/\s+/g, " ");
12
+ }
13
+
14
+ export interface RiskPattern {
15
+ name: string;
16
+ test: (cmd: string) => boolean;
17
+ }
18
+
19
+ /**
20
+ * Deny-confirm list (SPEC §5, FROZEN names). Matching is anchored to the
21
+ * CATASTROPHIC literal targets — over-matching benign commands (every
22
+ * absolute-path `rm -rf`, man pages, greps) generates confirm fatigue that
23
+ * defeats the grouped confirm's purpose (goal #11). `git push --force` must
24
+ * not match `--force-with-lease`.
25
+ */
26
+ export const RISK_PATTERNS: RiskPattern[] = [
27
+ // Bare "/" or "~/" targets only — not every absolute path.
28
+ { name: "rm -rf /", test: (c) => /rm -rf \/(?:\s|$)/.test(c) },
29
+ { name: "rm -rf ~", test: (c) => /rm -rf ~(?:\/(?:\s|$)|\s|$)/.test(c) },
30
+ {
31
+ name: "git push --force",
32
+ test: (c) => /git push\b[^|;&]*--force(?!-with-lease)/.test(c),
33
+ },
34
+ { name: "git reset --hard", test: (c) => c.includes("git reset --hard") },
35
+ // mkfs needs a device target: reading man pages / grepping docs is safe.
36
+ { name: "mkfs", test: (c) => /\bmkfs[^|;&]*\/dev\//.test(c) },
37
+ {
38
+ name: "dd of=/dev/",
39
+ test: (c) => /\bdd\b[^|;&]*\bof=\/dev\//.test(c),
40
+ },
41
+ { name: "chmod -R 777 /", test: (c) => /chmod -R 777 \/(?:\s|$)/.test(c) },
42
+ {
43
+ name: "curl | sh",
44
+ test: (c) => /curl\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c),
45
+ },
46
+ // `sudo rm` but not `sudo rmmod` (or other rm-prefixed tools).
47
+ { name: "sudo rm", test: (c) => /\bsudo\s+rm(?:\s|$)/.test(c) },
48
+ ];
49
+
50
+ /** Returns the matched risk pattern name, or null if the command is safe. */
51
+ export function matchRisk(normalizedCmd: string): string | null {
52
+ for (const p of RISK_PATTERNS) {
53
+ if (p.test(normalizedCmd)) return p.name;
54
+ }
55
+ return null;
56
+ }