protect-context 1.0.0 → 1.2.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.
- package/README.md +84 -3
- package/extension/index.ts +81 -24
- package/extension/lib/config.ts +45 -11
- package/extension/lib/contract.ts +127 -20
- package/extension/lib/events.ts +11 -4
- package/extension/lib/store.ts +176 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ The suite is **advisory-first**. It records evidence and makes unsafe or
|
|
|
29
29
|
repetitive behavior visible; it is not a sandbox and does not prove that a
|
|
30
30
|
change is correct. [`SPEC.md`](./SPEC.md) is the frozen v1.0 contract.
|
|
31
31
|
|
|
32
|
+

|
|
33
|
+
|
|
32
34
|
## Contents
|
|
33
35
|
|
|
34
36
|
- [Why this exists](#why-this-exists)
|
|
@@ -249,6 +251,8 @@ And the always-on one-line widget:
|
|
|
249
251
|
PC Δ+88L/6f 0loop ev5 ctx8%
|
|
250
252
|
```
|
|
251
253
|
|
|
254
|
+

|
|
255
|
+
|
|
252
256
|
## Interface comparison
|
|
253
257
|
|
|
254
258
|
| Capability | pi extension | MCP server | CLI |
|
|
@@ -314,8 +318,8 @@ protect-context status
|
|
|
314
318
|
protect-context verify "pytest -q" [--timeout 180]
|
|
315
319
|
protect-context evidence [--limit 5]
|
|
316
320
|
protect-context contract list
|
|
317
|
-
protect-context contract add "Keep the public API stable"
|
|
318
|
-
protect-context contract remove ID
|
|
321
|
+
protect-context contract add "Keep the public API stable" [--global]
|
|
322
|
+
protect-context contract remove ID [--global]
|
|
319
323
|
protect-context note correction "use rg, not grep" --tags search
|
|
320
324
|
protect-context journal [--limit 5] [--type decision]
|
|
321
325
|
protect-context orientation
|
|
@@ -350,6 +354,68 @@ fully checked. Version probes have a two-second per-tool timeout, share a
|
|
|
350
354
|
15-second overall deadline, and decode at most 4 KiB of output; a missing
|
|
351
355
|
version is reported as an incomplete preflight.
|
|
352
356
|
|
|
357
|
+
### Making it yours: the personal layer
|
|
358
|
+
|
|
359
|
+
The twelve seeds and the config defaults are the same for everyone, on purpose:
|
|
360
|
+
they are the floor that makes the guardrail comparable across machines. What is
|
|
361
|
+
*yours* goes in a personal layer that every project inherits:
|
|
362
|
+
|
|
363
|
+
```text
|
|
364
|
+
built-in defaults < ~/.config/protect-context/ < .agent-guard/
|
|
365
|
+
contract.json contract.json
|
|
366
|
+
config.json config.json
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The directory is `$PC_USER_DIR`, else `$XDG_CONFIG_HOME/protect-context`, else
|
|
370
|
+
`~/.config/protect-context`. Write it from the owner surfaces:
|
|
371
|
+
|
|
372
|
+
```console
|
|
373
|
+
$ protect-context contract add "Prefer stdlib over new dependencies" --global
|
|
374
|
+
added 13-prefer-stdlib-over-new-dependencies to personal layer
|
|
375
|
+
…
|
|
376
|
+
|
|
377
|
+
$ protect-context contract list # every project sees it
|
|
378
|
+
personal layer: 1 from /home/you/.config/protect-context/contract.json (applies to every project; a project row with the same id or text wins)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The same flag works in pi: `/guard-contract add <text> --global` and
|
|
382
|
+
`/guard-contract remove <id> --global`. Without it, writes go to the project
|
|
383
|
+
store as before, and `guard_contract` (the agent-callable tool) never touches
|
|
384
|
+
the personal layer.
|
|
385
|
+
|
|
386
|
+
Rules that make the layering predictable:
|
|
387
|
+
|
|
388
|
+
- **The project wins.** A project row with the same id overrides the personal
|
|
389
|
+
one, and a personal constraint identical in text to a project row is not
|
|
390
|
+
listed twice. Give a personal override the same id (e.g. `03-stay-in-scope`) to
|
|
391
|
+
reword a seed for every repo.
|
|
392
|
+
- **Defaults mean "no opinion".** The extension seeds `config.json` with every
|
|
393
|
+
default value; a project value equal to the built-in default therefore does not
|
|
394
|
+
pin it, so your personal `ctx_warn_pct` still applies. Any *different* project
|
|
395
|
+
value is an explicit choice and wins.
|
|
396
|
+
- **Nothing is created behind your back.** The layer is never seeded or repaired:
|
|
397
|
+
a missing file means "no personal constraints", and an unreadable one is
|
|
398
|
+
ignored in memory and left untouched. Deleting a personal constraint requires
|
|
399
|
+
`--global`; a project-scope remove says so instead of silently doing nothing.
|
|
400
|
+
- **Each layer has its own lease.** A `--global` write locks the personal
|
|
401
|
+
directory, not the project store, so two projects writing personal state cannot
|
|
402
|
+
race each other.
|
|
403
|
+
|
|
404
|
+
### Sharing a contract with a team
|
|
405
|
+
|
|
406
|
+
`.agent-guard/` is runtime state and normally gitignored. If a team wants one
|
|
407
|
+
shared owner contract, track only that file:
|
|
408
|
+
|
|
409
|
+
```gitignore
|
|
410
|
+
.agent-guard/*
|
|
411
|
+
!.agent-guard/contract.json
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Runtime files (events, evidence, journal, session state) stay local, while
|
|
415
|
+
`contract.json` becomes reviewable in pull requests. Personal constraints still
|
|
416
|
+
apply on top — each teammate's personal layer is weaker than the shared project
|
|
417
|
+
row.
|
|
418
|
+
|
|
353
419
|
The nine catastrophic patterns that trigger the pi confirmation are frozen and
|
|
354
420
|
deliberately narrow, so benign lookalikes stay silent:
|
|
355
421
|
|
|
@@ -362,6 +428,7 @@ deliberately narrow, so benign lookalikes stay silent:
|
|
|
362
428
|
| Variable | Effect |
|
|
363
429
|
|---|---|
|
|
364
430
|
| `PC_SESSION_ID` | Names the session used for evidence and loop views, so parallel hosts do not share a view |
|
|
431
|
+
| `PC_USER_DIR` | Overrides the personal-layer directory (default `$XDG_CONFIG_HOME/protect-context` or `~/.config/protect-context`) |
|
|
365
432
|
|
|
366
433
|
### Runtime store and concurrency
|
|
367
434
|
|
|
@@ -370,6 +437,18 @@ Runtime files stay in the frozen `.agent-guard/` layout and are gitignored:
|
|
|
370
437
|
`config.json` · `contract.json` · `journal.jsonl` · `events.jsonl` ·
|
|
371
438
|
`verify_runs.jsonl` · `state.json`
|
|
372
439
|
|
|
440
|
+
The optional personal layer lives outside the repository at
|
|
441
|
+
`~/.config/protect-context/` (`contract.json`, `config.json`) and is never
|
|
442
|
+
written implicitly; see *Making it yours* above.
|
|
443
|
+
|
|
444
|
+
Ledgers bound themselves instead of growing forever: once `events.jsonl`,
|
|
445
|
+
`verify_runs.jsonl` or `journal.jsonl` passes 1 MiB it rotates to
|
|
446
|
+
`<name>.1.jsonl`, keeping at most three rotated segments and dropping the oldest.
|
|
447
|
+
Reads concatenate the segments oldest-first, so recent rows and the highest
|
|
448
|
+
evidence id always survive; `budget` and loop streaks describe the retained
|
|
449
|
+
window. Session baselines are keyed per session inside `state.json`, so parallel
|
|
450
|
+
pi sessions in one repo no longer overwrite each other's watermarks.
|
|
451
|
+
|
|
373
452
|
Python and TypeScript use the same atomic-directory lease at
|
|
374
453
|
`.agent-guard/.lock.d`, with an owner token, PID/hostname checks, bounded
|
|
375
454
|
waiting, and conservative stale recovery. Python also takes its legacy local
|
|
@@ -418,7 +497,7 @@ machine: there are no network calls and no LLM calls in the guardrail logic.
|
|
|
418
497
|
|---|---|
|
|
419
498
|
| `session_start` | Seed config if missing, scan orientation, freeze the diff baseline, start the status widget |
|
|
420
499
|
| `before_agent_start` | Build the brief; inject orientation and/or brief as a hidden message when their digests changed |
|
|
421
|
-
| `tool_call` (bash) | Match the nine risky patterns → one grouped confirm; check the loop fingerprint → block at the threshold |
|
|
500
|
+
| `tool_call` (bash) | Match the nine risky patterns → one grouped confirm; check the loop fingerprint → block at the threshold; record the normalized command, with `ok=false` when the call was refused |
|
|
422
501
|
| `tool_execution_start` / `tool_execution_end` | Record bash outcomes and verify results into the shared loop state and evidence ledger |
|
|
423
502
|
| `turn_end` | Refresh the widget: diff stats, open loops, evidence count, context percentage |
|
|
424
503
|
| `session_compact` | Re-inject trusted context so compaction cannot silently erase it |
|
|
@@ -452,6 +531,8 @@ fingerprint across both bash and `guard_verify`. At `loop_block_after` the
|
|
|
452
531
|
attempt is refused, with the prior failure count and a change-strategy nudge —
|
|
453
532
|
the single cheapest intervention in the suite.
|
|
454
533
|
|
|
534
|
+

|
|
535
|
+
|
|
455
536
|
### Scope audit
|
|
456
537
|
|
|
457
538
|
Diffs are computed with `git` porcelain/numstat over tracked, staged, and
|
package/extension/index.ts
CHANGED
|
@@ -32,11 +32,19 @@ import {
|
|
|
32
32
|
loadState,
|
|
33
33
|
saveState,
|
|
34
34
|
readJsonl,
|
|
35
|
+
sessionState,
|
|
36
|
+
type SessionState,
|
|
35
37
|
storeDir,
|
|
36
38
|
verifyRunsPath,
|
|
37
39
|
withStoreLock,
|
|
38
40
|
} from "./lib/store.ts";
|
|
39
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
addConstraintWithStatus,
|
|
43
|
+
contractLines,
|
|
44
|
+
loadContract,
|
|
45
|
+
personalConstraintCount,
|
|
46
|
+
removeConstraint,
|
|
47
|
+
} from "./lib/contract.ts";
|
|
40
48
|
import {
|
|
41
49
|
appendJournal,
|
|
42
50
|
isJournalType,
|
|
@@ -81,6 +89,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
89
|
let session = { id: "", started: "", gitHead: null as string | null, gitUnborn: false };
|
|
82
90
|
let cfg: GuardConfig = { ...DEFAULT_CONFIG, preflight_tools: [...DEFAULT_CONFIG.preflight_tools] };
|
|
83
91
|
let state = defaultState("", null);
|
|
92
|
+
// Live view of THIS session's baseline inside `state.sessions`; the scratch
|
|
93
|
+
// default only covers handlers that fire before session_start.
|
|
94
|
+
let view: SessionState = {
|
|
95
|
+
started: "",
|
|
96
|
+
git_head: null,
|
|
97
|
+
brief_digest: null,
|
|
98
|
+
notify_watermarks: { ctx_pct: 0, diff_lines: 0, diff_files: 0 },
|
|
99
|
+
};
|
|
84
100
|
const startTimes = new Map<string, { start: number; tool: string; cmd: string | null }>();
|
|
85
101
|
let lastDiff = { lines: 0, files: 0, available: false, lowerBound: false };
|
|
86
102
|
let lastCtxPct: number | null = null;
|
|
@@ -146,7 +162,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
162
|
return `pc-${Math.random().toString(36).slice(2, 10)}`;
|
|
147
163
|
}
|
|
148
164
|
|
|
149
|
-
async function runGit(
|
|
165
|
+
async function runGit(_ctx: ExtensionContext, args: string[]): Promise<string | null> {
|
|
150
166
|
try {
|
|
151
167
|
const res = await pi.exec("git", args, { timeout: 5000 });
|
|
152
168
|
if (res.code !== 0) return null;
|
|
@@ -492,19 +508,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
492
508
|
loadContract(ctx.cwd); // seeds contract.json from DEFAULT_CONTRACT if missing
|
|
493
509
|
|
|
494
510
|
const prev = loadState(ctx.cwd);
|
|
495
|
-
|
|
496
|
-
|
|
511
|
+
state = prev ?? defaultState(session.id, null);
|
|
512
|
+
// A session keeps ITS OWN baseline: a second pi session in the same
|
|
513
|
+
// project must not reset this one's digest or watermarks (SPEC §2).
|
|
514
|
+
const returning = prev !== null && prev.sessions[session.id] !== undefined;
|
|
515
|
+
view = sessionState(state, session.id);
|
|
516
|
+
if (returning && view.started !== "") {
|
|
497
517
|
// Extension reloads can fire session_start again for the same pi
|
|
498
518
|
// session. Preserve the original baseline from state instead of
|
|
499
519
|
// silently resetting it to a newer commit.
|
|
500
|
-
session.started =
|
|
501
|
-
session.gitHead =
|
|
520
|
+
session.started = view.started;
|
|
521
|
+
session.gitHead = view.git_head;
|
|
502
522
|
} else {
|
|
503
523
|
session.started = new Date().toISOString();
|
|
504
524
|
session.gitHead = head?.trim() || null;
|
|
505
|
-
|
|
506
|
-
|
|
525
|
+
view.started = session.started;
|
|
526
|
+
view.git_head = session.gitHead;
|
|
527
|
+
view.brief_digest = null;
|
|
507
528
|
}
|
|
529
|
+
state.current_session = { id: session.id, started: session.started, git_head: session.gitHead };
|
|
530
|
+
saveState(ctx.cwd, state);
|
|
508
531
|
// A null baseline inside a repository means the session began before a
|
|
509
532
|
// first commit (or before repository creation). Keep using the empty
|
|
510
533
|
// tree even if HEAD appears later, so commits cannot erase session diff.
|
|
@@ -541,7 +564,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
541
564
|
lastEvidence: last ? { id: last.id, exit: last.exit, cmd: last.cmd, ms: last.ms } : null,
|
|
542
565
|
});
|
|
543
566
|
const digest = sha1Hex(brief);
|
|
544
|
-
const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" ||
|
|
567
|
+
const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" || view.brief_digest !== digest);
|
|
545
568
|
|
|
546
569
|
// Orientation (goal #4): injected once per distinct digest, like the
|
|
547
570
|
// brief. Lazily re-scanned if session_start did not populate it.
|
|
@@ -563,7 +586,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
563
586
|
if (parts.length === 0) return undefined;
|
|
564
587
|
|
|
565
588
|
if (orientDue && orientDigest !== null) state.counters.orient_sha1 = orientDigest;
|
|
566
|
-
if (briefDue)
|
|
589
|
+
if (briefDue) view.brief_digest = digest;
|
|
567
590
|
saveState(ctx.cwd, state);
|
|
568
591
|
return { message: { customType: BRIEF_CUSTOM_TYPE, content: parts.join("\n\n"), display: false } };
|
|
569
592
|
}),
|
|
@@ -590,6 +613,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
590
613
|
// block reason as the result) — mark it so the end handler does
|
|
591
614
|
// not record the decline as a failed command.
|
|
592
615
|
blockedToolCallIds.add(event.toolCallId);
|
|
616
|
+
// Record the refused attempt: otherwise the shared audit trail keeps
|
|
617
|
+
// no trace of the command the guard refused to run (SPEC §2).
|
|
618
|
+
withStoreLock(storeDir(ctx.cwd), () => {
|
|
619
|
+
appendJsonlLocked(
|
|
620
|
+
eventsPath(ctx.cwd),
|
|
621
|
+
toolCallEvent(session.id, "bash", { cmd: fp, blocked: true }),
|
|
622
|
+
);
|
|
623
|
+
loopLedgerMemo = loopLedgerStamp(ctx.cwd);
|
|
624
|
+
});
|
|
593
625
|
return { block: true, reason: `guard: risky command declined (${risk})` };
|
|
594
626
|
}
|
|
595
627
|
} else {
|
|
@@ -614,9 +646,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
614
646
|
const blockedFailures = withStoreLock(storeDir(ctx.cwd), () => {
|
|
615
647
|
if (loopLedgerMemo !== loopLedgerStamp(ctx.cwd)) rebuildLoopStateUnlocked(ctx.cwd);
|
|
616
648
|
const failures = loopState.get(fp)?.failures ?? 0;
|
|
617
|
-
if (shouldBlockLoop(failures, cfg.loop_block_after))
|
|
618
|
-
|
|
619
|
-
|
|
649
|
+
if (shouldBlockLoop(failures, cfg.loop_block_after)) {
|
|
650
|
+
appendJsonlLocked(
|
|
651
|
+
eventsPath(ctx.cwd),
|
|
652
|
+
toolCallEvent(session.id, "bash", { cmd: fp, blocked: true }),
|
|
653
|
+
);
|
|
654
|
+
loopLedgerMemo = loopLedgerStamp(ctx.cwd);
|
|
655
|
+
return failures;
|
|
656
|
+
}
|
|
657
|
+
appendJsonlLocked(eventsPath(ctx.cwd), toolCallEvent(session.id, "bash", { cmd: fp }));
|
|
620
658
|
return null;
|
|
621
659
|
});
|
|
622
660
|
if (blockedFailures !== null) {
|
|
@@ -705,10 +743,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
705
743
|
const step = Math.floor(usage.percent / 10) * 10;
|
|
706
744
|
const contextDue =
|
|
707
745
|
step >= cfg.ctx_warn_pct &&
|
|
708
|
-
(step >
|
|
746
|
+
(step > view.notify_watermarks.ctx_pct ||
|
|
709
747
|
(cfg.ctx_warn_pct === 0 && state.counters.ctx_zero_notified !== 1));
|
|
710
748
|
if (contextDue) {
|
|
711
|
-
|
|
749
|
+
view.notify_watermarks.ctx_pct = step;
|
|
712
750
|
markZeroThreshold(cfg.ctx_warn_pct, "ctx_zero_notified");
|
|
713
751
|
saveState(ctx.cwd, state);
|
|
714
752
|
if (ctx.hasUI) {
|
|
@@ -733,7 +771,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
733
771
|
};
|
|
734
772
|
if (diff.repo && diff.available) {
|
|
735
773
|
const { files, lines } = diff;
|
|
736
|
-
const wm =
|
|
774
|
+
const wm = view.notify_watermarks;
|
|
737
775
|
if (lines >= cfg.diff_hard_lines) {
|
|
738
776
|
if (thresholdDue(lines, cfg.diff_hard_lines, wm.diff_lines, "diff_hard_zero_notified")) {
|
|
739
777
|
wm.diff_lines = Math.max(wm.diff_lines, cfg.diff_hard_lines);
|
|
@@ -777,7 +815,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
777
815
|
// trusted journal entries and orientation even when their text is unchanged.
|
|
778
816
|
pi.on("session_compact", async (_event, ctx) =>
|
|
779
817
|
attempt(ctx, async () => {
|
|
780
|
-
|
|
818
|
+
view.brief_digest = null;
|
|
781
819
|
delete state.counters.orient_sha1;
|
|
782
820
|
saveState(ctx.cwd, state);
|
|
783
821
|
return undefined;
|
|
@@ -820,15 +858,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
820
858
|
handler: async (args, ctx) =>
|
|
821
859
|
attempt(ctx, async () => {
|
|
822
860
|
const trimmed = (args ?? "").trim();
|
|
861
|
+
// `--global` edits the personal layer (~/.config/protect-context) so one
|
|
862
|
+
// constraint applies to every project; it is a owner-only surface.
|
|
863
|
+
const global_requested = /(^|\s)--global(\s|$)/.test(trimmed);
|
|
823
864
|
const parts = trimmed.split(/\s+/);
|
|
824
865
|
const verb = parts[0] ?? "";
|
|
825
|
-
const rest = parts
|
|
866
|
+
const rest = parts
|
|
867
|
+
.slice(1)
|
|
868
|
+
.filter((part) => part !== "--global")
|
|
869
|
+
.join(" ")
|
|
870
|
+
.trim();
|
|
871
|
+
const scope = global_requested ? "user" : "project";
|
|
826
872
|
if (verb === "" || verb === "list") {
|
|
827
873
|
const current = loadContract(ctx.cwd);
|
|
828
874
|
const lines = contractLines(current);
|
|
875
|
+
const personal = personalConstraintCount();
|
|
829
876
|
if (ctx.hasUI) {
|
|
877
|
+
const note = personal > 0 ? `\npersonal layer: ${personal} (applies to every project)` : "";
|
|
830
878
|
ctx.ui.notify(
|
|
831
|
-
`contract (${current.constraints.length}):\n${lines.join("\n")}\nNudge: add missing constraints with /guard-contract add <text>.`,
|
|
879
|
+
`contract (${current.constraints.length}):\n${lines.join("\n")}${note}\nNudge: add missing constraints with /guard-contract add <text>.`,
|
|
832
880
|
"info",
|
|
833
881
|
);
|
|
834
882
|
}
|
|
@@ -836,14 +884,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
836
884
|
}
|
|
837
885
|
if (verb === "add") {
|
|
838
886
|
if (rest === "") {
|
|
839
|
-
if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract add <text>", "warning");
|
|
887
|
+
if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract add <text> [--global]", "warning");
|
|
840
888
|
return undefined;
|
|
841
889
|
}
|
|
842
890
|
// Slash commands are direct owner interaction and therefore trusted
|
|
843
891
|
// by brief prioritization; model-callable writes use provenance tags.
|
|
844
|
-
const result = addConstraintWithStatus(ctx.cwd, rest, "user");
|
|
892
|
+
const result = addConstraintWithStatus(ctx.cwd, rest, "user", scope);
|
|
845
893
|
const c = result.constraint;
|
|
846
894
|
const total = result.total;
|
|
895
|
+
const where = scope === "user" ? " (personal layer — every project)" : "";
|
|
847
896
|
if (ctx.hasUI) {
|
|
848
897
|
// SPEC §5 caps the re-injected brief at 12 contract lines: a
|
|
849
898
|
// constraint past the cap would be silently DROPPED while the
|
|
@@ -852,21 +901,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
852
901
|
const ack = !result.added
|
|
853
902
|
? `constraint ${c.id} already exists\nNudge: no duplicate was written.`
|
|
854
903
|
: over
|
|
855
|
-
? `added ${c.id}\nwarning: ${total} constraints exceed the ${BRIEF_MAX_CONTRACT_LINES}-line brief cap — ` +
|
|
904
|
+
? `added ${c.id}${where}\nwarning: ${total} constraints exceed the ${BRIEF_MAX_CONTRACT_LINES}-line brief cap — ` +
|
|
856
905
|
`only the ${BRIEF_MAX_CONTRACT_LINES} highest-priority owner/seed constraints are re-injected.` +
|
|
857
906
|
`\nNudge: keep the contract tight — fewer, sharper constraints survive re-injection.`
|
|
858
|
-
: `added ${c.id}\nNudge: the brief re-injects on your next prompt.`;
|
|
907
|
+
: `added ${c.id}${where}\nNudge: the brief re-injects on your next prompt.`;
|
|
859
908
|
ctx.ui.notify(ack, result.added && over ? "warning" : "info");
|
|
860
909
|
}
|
|
861
910
|
return undefined;
|
|
862
911
|
}
|
|
863
912
|
if (verb === "remove") {
|
|
864
|
-
const removed = rest !== "" && removeConstraint(ctx.cwd, rest);
|
|
913
|
+
const removed = rest !== "" && removeConstraint(ctx.cwd, rest, scope);
|
|
865
914
|
const safeId = truncLine(rest, 80);
|
|
915
|
+
// A project-scope remove cannot delete personal state: point at it
|
|
916
|
+
// instead of claiming the id does not exist.
|
|
917
|
+
const inPersonalLayer =
|
|
918
|
+
!removed &&
|
|
919
|
+
scope === "project" &&
|
|
920
|
+
loadContract(ctx.cwd).constraints.some((constraint) => constraint.id === rest);
|
|
866
921
|
if (ctx.hasUI) {
|
|
867
922
|
ctx.ui.notify(
|
|
868
923
|
removed
|
|
869
924
|
? `removed ${safeId}\nNudge: the contract re-injects on your next prompt.`
|
|
925
|
+
: inPersonalLayer
|
|
926
|
+
? `${safeId} lives in the personal layer\nNudge: re-run with /guard-contract remove ${safeId} --global.`
|
|
870
927
|
: `no constraint '${safeId}'\nNudge: run /guard-contract list first.`,
|
|
871
928
|
removed ? "info" : "warning",
|
|
872
929
|
);
|
package/extension/lib/config.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { DEFAULT_CONFIG, type GuardConfig } from "./defaults.ts";
|
|
7
|
-
import { configPath, readJson, storeDir, withStoreLock, writeJson } from "./store.ts";
|
|
7
|
+
import { configPath, readJson, storeDir, userConfigPath, withStoreLock, writeJson } from "./store.ts";
|
|
8
8
|
|
|
9
9
|
function isInteger(v: unknown): v is number {
|
|
10
10
|
return typeof v === "number" && Number.isInteger(v);
|
|
@@ -19,8 +19,11 @@ function strArray(v: unknown): string[] | null {
|
|
|
19
19
|
* Merge a partial config over the frozen defaults. Unknown keys are ignored;
|
|
20
20
|
* wrong-typed values fall back to the default (deterministic, test-pinned).
|
|
21
21
|
*/
|
|
22
|
-
export function mergeConfig(
|
|
23
|
-
|
|
22
|
+
export function mergeConfig(
|
|
23
|
+
partial: Record<string, unknown> | null | undefined,
|
|
24
|
+
base: GuardConfig = DEFAULT_CONFIG,
|
|
25
|
+
): GuardConfig {
|
|
26
|
+
const cfg: GuardConfig = { ...base, preflight_tools: [...base.preflight_tools] };
|
|
24
27
|
if (!partial || typeof partial !== "object") return cfg;
|
|
25
28
|
|
|
26
29
|
const p = partial as Record<string, unknown>;
|
|
@@ -38,20 +41,51 @@ export function mergeConfig(partial: Record<string, unknown> | null | undefined)
|
|
|
38
41
|
|
|
39
42
|
export interface LoadedConfig {
|
|
40
43
|
config: GuardConfig;
|
|
44
|
+
/** Unknown keys from the personal/project files, preserved but never
|
|
45
|
+
* interpreted by the guard (strongest layer wins). Kept out of
|
|
46
|
+
* `GuardConfig` so a typo in a known key still fails to type-check. */
|
|
47
|
+
extras: Record<string, unknown>;
|
|
41
48
|
path: string;
|
|
42
49
|
existed: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
53
|
+
|
|
54
|
+
/** Unknown keys of one layer, for pass-through only. */
|
|
55
|
+
function layerExtras(raw: Record<string, unknown> | null): Record<string, unknown> {
|
|
56
|
+
if (!raw) return {};
|
|
57
|
+
return Object.fromEntries(Object.entries(raw).filter(([key]) => !KNOWN_KEYS.has(key)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Project values identical to the built-in default mean "no opinion". A seeded
|
|
61
|
+
* project file records every default, so reading those as explicit choices
|
|
62
|
+
* would shadow the personal layer forever. */
|
|
63
|
+
function explicitProjectKeys(raw: Record<string, unknown> | null): Record<string, unknown> | undefined {
|
|
64
|
+
if (!raw) return undefined;
|
|
65
|
+
const defaults: Record<string, unknown> = Object.fromEntries(Object.entries(DEFAULT_CONFIG));
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
68
|
+
if (JSON.stringify(value) === JSON.stringify(defaults[key])) continue;
|
|
69
|
+
out[key] = value;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Load `.agent-guard/config.json` over the personal layer
|
|
75
|
+
* (`~/.config/protect-context/config.json`) over the defaults (SPEC §2).
|
|
76
|
+
* Extension startup can request an on-disk seed so an extension-only install
|
|
77
|
+
* has the documented configuration surface without the Python CLI; the seed is
|
|
78
|
+
* the built-in defaults, never the merged view. Corrupt files are never
|
|
79
|
+
* overwritten. */
|
|
49
80
|
function readLoadedConfig(cwd: string): LoadedConfig {
|
|
50
81
|
const path = configPath(cwd);
|
|
51
82
|
const existed = existsSync(path);
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
83
|
+
const personalRaw = readJson<Record<string, unknown>>(userConfigPath());
|
|
84
|
+
const projectRaw = readJson<Record<string, unknown>>(path);
|
|
85
|
+
const personal = mergeConfig(personalRaw ?? undefined);
|
|
86
|
+
const config = mergeConfig(explicitProjectKeys(projectRaw), personal);
|
|
87
|
+
const extras = { ...layerExtras(personalRaw), ...layerExtras(projectRaw) };
|
|
88
|
+
return { config, extras, path, existed };
|
|
55
89
|
}
|
|
56
90
|
|
|
57
91
|
export function loadConfig(cwd: string, seedMissing = false): LoadedConfig {
|
|
@@ -61,7 +95,7 @@ export function loadConfig(cwd: string, seedMissing = false): LoadedConfig {
|
|
|
61
95
|
// by defaults between an optimistic exists() and writeJson().
|
|
62
96
|
return withStoreLock(storeDir(cwd), () => {
|
|
63
97
|
const loaded = readLoadedConfig(cwd);
|
|
64
|
-
if (!loaded.existed) writeJson(loaded.path,
|
|
98
|
+
if (!loaded.existed) writeJson(loaded.path, DEFAULT_CONFIG);
|
|
65
99
|
return loaded;
|
|
66
100
|
});
|
|
67
101
|
}
|
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { DEFAULT_CONTRACT, type Constraint, type Contract } from "./defaults.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
contractPath,
|
|
9
|
+
readJson,
|
|
10
|
+
storeDir,
|
|
11
|
+
userContractPath,
|
|
12
|
+
userDir,
|
|
13
|
+
withStoreLock,
|
|
14
|
+
writeJson,
|
|
15
|
+
} from "./store.ts";
|
|
8
16
|
import { normalizeOneLine } from "./text.ts";
|
|
9
17
|
|
|
10
18
|
/** Free-text cap (chars): constraint text flows into re-injected briefs (goal #12). */
|
|
@@ -75,8 +83,41 @@ function saveContractUnlocked(cwd: string, contract: Contract): void {
|
|
|
75
83
|
writeJson(contractPath(cwd), contract);
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/** Personal-layer rows: a missing file means "no personal constraints" (never
|
|
87
|
+
* seeded), and an unreadable one is ignored in memory so a transient error can
|
|
88
|
+
* never become a write. SPEC §2: the personal layer is owner-only surface. */
|
|
89
|
+
function personalConstraints(): Constraint[] {
|
|
90
|
+
const data = readJson<unknown>(userContractPath());
|
|
91
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
92
|
+
const rows = (data as { constraints?: unknown }).constraints;
|
|
93
|
+
return Array.isArray(rows) ? rows.filter(isConstraint) : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Project rows first, then personal rows the project does not already carry
|
|
97
|
+
* (same id or identical text): the project overrides the personal layer. */
|
|
98
|
+
export function mergeConstraints(project: Constraint[], personal: Constraint[]): Constraint[] {
|
|
99
|
+
const out: Constraint[] = [];
|
|
100
|
+
const ids = new Set<string>();
|
|
101
|
+
const signatures = new Set<string>();
|
|
102
|
+
for (const row of [...project, ...personal]) {
|
|
103
|
+
const id = normalizeOneLine(row.id, 80);
|
|
104
|
+
const signature = normalizeOneLine(row.text, TEXT_CAP);
|
|
105
|
+
if (ids.has(id) || signatures.has(signature)) continue;
|
|
106
|
+
ids.add(id);
|
|
107
|
+
signatures.add(signature);
|
|
108
|
+
out.push(row);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Effective contract (project + personal) while the caller holds the lease. */
|
|
114
|
+
function effectiveContractUnlocked(cwd: string, forMutation = false): Contract {
|
|
115
|
+
const project = loadContractUnlocked(cwd, forMutation);
|
|
116
|
+
return { version: 1, constraints: mergeConstraints(project.constraints, personalConstraints()) };
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
export function loadContract(cwd: string): Contract {
|
|
79
|
-
return withStoreLock(storeDir(cwd), () =>
|
|
120
|
+
return withStoreLock(storeDir(cwd), () => effectiveContractUnlocked(cwd));
|
|
80
121
|
}
|
|
81
122
|
|
|
82
123
|
export function saveContract(cwd: string, contract: Contract): void {
|
|
@@ -99,36 +140,102 @@ export interface AddConstraintResult {
|
|
|
99
140
|
total: number;
|
|
100
141
|
}
|
|
101
142
|
|
|
102
|
-
export
|
|
143
|
+
export type ContractScope = "project" | "user";
|
|
144
|
+
|
|
145
|
+
/** Lease for the layer being written: the project store, or the personal
|
|
146
|
+
* directory (two projects must never share the wrong lease). */
|
|
147
|
+
function scopeRoot(cwd: string, scope: ContractScope): string {
|
|
148
|
+
return scope === "user" ? userDir() : storeDir(cwd);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Personal rows for a read-modify-write cycle. A missing personal layer is not
|
|
152
|
+
* an error (it is never seeded); a damaged one fails closed so a mutation
|
|
153
|
+
* cannot silently erase owner data. */
|
|
154
|
+
function personalForMutation(): Constraint[] {
|
|
155
|
+
const file = userContractPath();
|
|
156
|
+
if (!existsSync(file)) return [];
|
|
157
|
+
const data = readJson<unknown>(file);
|
|
158
|
+
if (!data || typeof data !== "object" || !Array.isArray((data as { constraints?: unknown }).constraints)) {
|
|
159
|
+
throw new ContractIntegrityError(`personal contract is malformed; repair ${file} before mutating it`);
|
|
160
|
+
}
|
|
161
|
+
const rows = (data as { constraints: unknown[] }).constraints;
|
|
162
|
+
const valid = rows.filter(isConstraint);
|
|
163
|
+
if (valid.length !== rows.length) {
|
|
164
|
+
throw new ContractIntegrityError(`personal contract contains malformed rows; repair ${file} before mutating it`);
|
|
165
|
+
}
|
|
166
|
+
return valid;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Project rows without seeding or writing, as a basis for personal-layer
|
|
170
|
+
* mutations (never hold the user lease while writing the project store). */
|
|
171
|
+
function projectRowsReadonly(cwd: string): Constraint[] {
|
|
172
|
+
const data = readJson<unknown>(contractPath(cwd));
|
|
173
|
+
if (!data || typeof data !== "object" || !Array.isArray((data as { constraints?: unknown }).constraints)) {
|
|
174
|
+
return cloneDefaultContract().constraints;
|
|
175
|
+
}
|
|
176
|
+
const rows = (data as { constraints: unknown[] }).constraints;
|
|
177
|
+
const valid = rows.filter(isConstraint);
|
|
178
|
+
return rows.length > 0 && valid.length === 0 ? cloneDefaultContract().constraints : valid;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scopeRows(cwd: string, scope: ContractScope): Constraint[] {
|
|
182
|
+
return scope === "user" ? personalForMutation() : loadContractUnlocked(cwd, true).constraints;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function saveScope(cwd: string, scope: ContractScope, constraints: Constraint[]): void {
|
|
186
|
+
const contract: Contract = { version: 1, constraints };
|
|
187
|
+
if (scope === "user") {
|
|
188
|
+
writeJson(userContractPath(), contract);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
saveContractUnlocked(cwd, contract);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Constraints the personal layer contributes to the effective contract. */
|
|
195
|
+
export function personalConstraintCount(): number {
|
|
196
|
+
return personalConstraints().length;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function addConstraintWithStatus(
|
|
200
|
+
cwd: string,
|
|
201
|
+
text: string,
|
|
202
|
+
source = "agent",
|
|
203
|
+
scope: ContractScope = "project",
|
|
204
|
+
): AddConstraintResult {
|
|
103
205
|
const cleanText = normalizeOneLine(text, TEXT_CAP);
|
|
104
206
|
if (cleanText === "") throw new Error("constraint text must not be blank");
|
|
105
|
-
return withStoreLock(
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
207
|
+
return withStoreLock(scopeRoot(cwd, scope), () => {
|
|
208
|
+
const rows = scopeRows(cwd, scope);
|
|
209
|
+
// Dedupe and number against the EFFECTIVE contract: a personal constraint
|
|
210
|
+
// must never be duplicated into a project, and ids never collide.
|
|
211
|
+
const effective =
|
|
212
|
+
scope === "user"
|
|
213
|
+
? mergeConstraints(projectRowsReadonly(cwd), personalConstraints())
|
|
214
|
+
: mergeConstraints(rows, personalConstraints());
|
|
215
|
+
const existing = effective.find((constraint) => normalizeOneLine(constraint.text, TEXT_CAP) === cleanText);
|
|
216
|
+
if (existing) return { constraint: existing, added: false, total: effective.length };
|
|
109
217
|
const constraint: Constraint = {
|
|
110
|
-
id: nextConstraintId(
|
|
218
|
+
id: nextConstraintId(effective),
|
|
111
219
|
text: cleanText,
|
|
112
220
|
added: new Date().toISOString().slice(0, 10),
|
|
113
221
|
source: normalizeOneLine(source, 40) || "agent",
|
|
114
222
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return { constraint, added: true, total:
|
|
223
|
+
rows.push(constraint);
|
|
224
|
+
saveScope(cwd, scope, rows);
|
|
225
|
+
return { constraint, added: true, total: effective.length + 1 };
|
|
118
226
|
});
|
|
119
227
|
}
|
|
120
228
|
|
|
121
|
-
export function addConstraint(cwd: string, text: string, source = "agent"): Constraint {
|
|
122
|
-
return addConstraintWithStatus(cwd, text, source).constraint;
|
|
229
|
+
export function addConstraint(cwd: string, text: string, source = "agent", scope: ContractScope = "project"): Constraint {
|
|
230
|
+
return addConstraintWithStatus(cwd, text, source, scope).constraint;
|
|
123
231
|
}
|
|
124
232
|
|
|
125
|
-
export function removeConstraint(cwd: string, id: string): boolean {
|
|
126
|
-
return withStoreLock(
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
saveContractUnlocked(cwd, contract);
|
|
233
|
+
export function removeConstraint(cwd: string, id: string, scope: ContractScope = "project"): boolean {
|
|
234
|
+
return withStoreLock(scopeRoot(cwd, scope), () => {
|
|
235
|
+
const rows = scopeRows(cwd, scope);
|
|
236
|
+
const kept = rows.filter((c) => c.id !== id);
|
|
237
|
+
if (kept.length === rows.length) return false;
|
|
238
|
+
saveScope(cwd, scope, kept);
|
|
132
239
|
return true;
|
|
133
240
|
});
|
|
134
241
|
}
|
package/extension/lib/events.ts
CHANGED
|
@@ -10,10 +10,11 @@ export interface EventRecord {
|
|
|
10
10
|
session: string;
|
|
11
11
|
kind: EventKind;
|
|
12
12
|
tool: string;
|
|
13
|
+
/** null on an ordinary tool_call; false when the call was refused. */
|
|
13
14
|
ok: boolean | null;
|
|
14
15
|
ms: number | null;
|
|
15
16
|
out_chars: number | null;
|
|
16
|
-
/**
|
|
17
|
+
/** bash rows only, fingerprint-normalized (tool_call and tool_result). */
|
|
17
18
|
cmd: string | null;
|
|
18
19
|
exit: number | null;
|
|
19
20
|
}
|
|
@@ -22,16 +23,22 @@ function nowIso(): string {
|
|
|
22
23
|
return new Date().toISOString();
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export function toolCallEvent(
|
|
26
|
+
export function toolCallEvent(
|
|
27
|
+
session: string,
|
|
28
|
+
tool: string,
|
|
29
|
+
opts: { cmd?: string | null; blocked?: boolean } = {},
|
|
30
|
+
): EventRecord {
|
|
26
31
|
return {
|
|
27
32
|
ts: nowIso(),
|
|
28
33
|
session,
|
|
29
34
|
kind: "tool_call",
|
|
30
35
|
tool,
|
|
31
|
-
|
|
36
|
+
// A refused call (loop block / declined risk confirm) has no result, so the
|
|
37
|
+
// attempt is recorded as ok=false; an ordinary call has no outcome yet.
|
|
38
|
+
ok: opts.blocked ? false : null,
|
|
32
39
|
ms: null,
|
|
33
40
|
out_chars: null,
|
|
34
|
-
cmd: null,
|
|
41
|
+
cmd: opts.cmd ?? null,
|
|
35
42
|
exit: null,
|
|
36
43
|
};
|
|
37
44
|
}
|
package/extension/lib/store.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
realpathSync,
|
|
21
21
|
} from "node:fs";
|
|
22
22
|
import { createHash, randomBytes } from "node:crypto";
|
|
23
|
-
import { hostname } from "node:os";
|
|
24
|
-
import { dirname, join } from "node:path";
|
|
23
|
+
import { hostname, homedir } from "node:os";
|
|
24
|
+
import { basename, dirname, join } from "node:path";
|
|
25
25
|
import { performance } from "node:perf_hooks";
|
|
26
26
|
|
|
27
27
|
export const STORE_DIR_NAME = ".agent-guard";
|
|
@@ -36,6 +36,26 @@ export function ensureStore(cwd: string): string {
|
|
|
36
36
|
return dir;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export const USER_DIR_ENV = "PC_USER_DIR";
|
|
40
|
+
|
|
41
|
+
/** Personal layer directory (SPEC §2): PC_USER_DIR, else $XDG_CONFIG_HOME,
|
|
42
|
+
* else ~/.config, always under `protect-context/`. Read at call time so tests
|
|
43
|
+
* and dotfiles-managed machines can redirect it. */
|
|
44
|
+
export function userDir(): string {
|
|
45
|
+
const override = (process.env[USER_DIR_ENV] ?? "").trim();
|
|
46
|
+
if (override !== "") return override;
|
|
47
|
+
const xdg = (process.env.XDG_CONFIG_HOME ?? "").trim();
|
|
48
|
+
return join(xdg !== "" ? xdg : join(homedir(), ".config"), "protect-context");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function userContractPath(): string {
|
|
52
|
+
return join(userDir(), "contract.json");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function userConfigPath(): string {
|
|
56
|
+
return join(userDir(), "config.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
export function configPath(cwd: string): string {
|
|
40
60
|
return join(storeDir(cwd), "config.json");
|
|
41
61
|
}
|
|
@@ -338,6 +358,7 @@ export function withStoreLock<T>(dir: string, fn: () => T, options: StoreLockOpt
|
|
|
338
358
|
/** Append one JSON record as a single O_APPEND line write. The caller must
|
|
339
359
|
* already hold the store lock (see withStoreLock). */
|
|
340
360
|
export function appendJsonlLocked(file: string, record: unknown): void {
|
|
361
|
+
maybeRotateLedger(file);
|
|
341
362
|
appendFileSync(file, `${JSON.stringify(record)}\n`, "utf8");
|
|
342
363
|
}
|
|
343
364
|
|
|
@@ -371,11 +392,50 @@ export function fileStamp(file: string): string | null {
|
|
|
371
392
|
|
|
372
393
|
const jsonlCache = new Map<string, { mtimeMs: number; size: number; data: unknown[] }>();
|
|
373
394
|
|
|
374
|
-
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
|
|
378
|
-
|
|
395
|
+
/** Ledger bounding (SPEC §2): a ledger rotates to `<stem>.N.jsonl` once it
|
|
396
|
+
* passes LEDGER_MAX_BYTES, keeping at most LEDGER_SEGMENTS rotated files.
|
|
397
|
+
* Readers concatenate oldest-first, so "last N rows" stays correct and the
|
|
398
|
+
* newest evidence id (the basis for `ev-NNNN`) is never rotated away. */
|
|
399
|
+
export const LEDGER_MAX_BYTES = 1_048_576;
|
|
400
|
+
export const LEDGER_SEGMENTS = 3;
|
|
401
|
+
|
|
402
|
+
function segmentPath(file: string, index: number): string {
|
|
403
|
+
const name = basename(file);
|
|
404
|
+
const dot = name.lastIndexOf(".");
|
|
405
|
+
const stem = dot === -1 ? name : name.slice(0, dot);
|
|
406
|
+
const ext = dot === -1 ? "" : name.slice(dot);
|
|
407
|
+
return join(dirname(file), `${stem}.${index}${ext}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Existing ledger files for `file`, OLDEST first: rotated segments (highest
|
|
411
|
+
* index first) and the live file last. */
|
|
412
|
+
export function ledgerFiles(file: string): string[] {
|
|
413
|
+
const files: string[] = [];
|
|
414
|
+
for (let index = LEDGER_SEGMENTS; index >= 1; index -= 1) {
|
|
415
|
+
const segment = segmentPath(file, index);
|
|
416
|
+
if (existsSync(segment)) files.push(segment);
|
|
417
|
+
}
|
|
418
|
+
if (existsSync(file)) files.push(file);
|
|
419
|
+
return files;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function maybeRotateLedger(file: string): void {
|
|
423
|
+
let size: number;
|
|
424
|
+
try {
|
|
425
|
+
size = statSync(file).size;
|
|
426
|
+
} catch {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (size < LEDGER_MAX_BYTES) return;
|
|
430
|
+
// Caller holds the store lease, so the shift cannot race another writer.
|
|
431
|
+
for (let index = LEDGER_SEGMENTS - 1; index >= 1; index -= 1) {
|
|
432
|
+
const src = segmentPath(file, index);
|
|
433
|
+
if (existsSync(src)) renameSync(src, segmentPath(file, index + 1));
|
|
434
|
+
}
|
|
435
|
+
renameSync(file, segmentPath(file, 1));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function readOneJsonl<T>(file: string): T[] {
|
|
379
439
|
const st = statOf(file);
|
|
380
440
|
if (!st) return [];
|
|
381
441
|
const cached = jsonlCache.get(file);
|
|
@@ -400,6 +460,15 @@ export function readJsonl<T>(file: string): T[] {
|
|
|
400
460
|
return data as T[];
|
|
401
461
|
}
|
|
402
462
|
|
|
463
|
+
/** Read a ledger into object records, oldest row first across rotated
|
|
464
|
+
* segments; missing file → []. Malformed and non-object lines are skipped,
|
|
465
|
+
* matching the Python ledger reader. Results are cached by mtime+size. */
|
|
466
|
+
export function readJsonl<T>(file: string): T[] {
|
|
467
|
+
const data: unknown[] = [];
|
|
468
|
+
for (const segment of ledgerFiles(file)) data.push(...readOneJsonl<unknown>(segment));
|
|
469
|
+
return data as T[];
|
|
470
|
+
}
|
|
471
|
+
|
|
403
472
|
export function readJson<T>(file: string): T | null {
|
|
404
473
|
if (!existsSync(file)) return null;
|
|
405
474
|
try {
|
|
@@ -430,21 +499,78 @@ export function writeJson(file: string, value: unknown): void {
|
|
|
430
499
|
// ---------------------------------------------------------------------------
|
|
431
500
|
// state.json (SPEC §2)
|
|
432
501
|
|
|
433
|
-
export interface
|
|
434
|
-
|
|
502
|
+
export interface SessionState {
|
|
503
|
+
started: string;
|
|
504
|
+
git_head: string | null;
|
|
435
505
|
brief_digest: string | null;
|
|
436
506
|
notify_watermarks: { ctx_pct: number; diff_lines: number; diff_files: number };
|
|
507
|
+
/** ISO timestamp of the last touch, used only to bound the file. */
|
|
508
|
+
updated?: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Baseline that belongs to ONE pi session. Concurrent sessions in one project
|
|
512
|
+
* keep separate entries so they cannot clobber each other (SPEC §2). */
|
|
513
|
+
export interface GuardState {
|
|
514
|
+
current_session: { id: string; started: string; git_head: string | null };
|
|
515
|
+
sessions: Record<string, SessionState>;
|
|
437
516
|
/** Opaque passthrough on the Python side; the extension stores digests here
|
|
438
517
|
* (e.g. the orientation digest) alongside numeric counters. */
|
|
439
518
|
counters: Record<string, number | string>;
|
|
440
519
|
}
|
|
441
520
|
|
|
521
|
+
/** How many per-session baselines to retain (newest first, current always). */
|
|
522
|
+
export const SESSION_KEEP = 8;
|
|
523
|
+
|
|
442
524
|
export function defaultState(sessionId: string, gitHead: string | null): GuardState {
|
|
443
525
|
return {
|
|
444
526
|
current_session: { id: sessionId, started: new Date().toISOString(), git_head: gitHead },
|
|
527
|
+
sessions: {},
|
|
528
|
+
counters: {},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function freshSession(started: string, gitHead: string | null): SessionState {
|
|
533
|
+
return {
|
|
534
|
+
started,
|
|
535
|
+
git_head: gitHead,
|
|
445
536
|
brief_digest: null,
|
|
446
537
|
notify_watermarks: { ctx_pct: 0, diff_lines: 0, diff_files: 0 },
|
|
447
|
-
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Live per-session entry of `state`, created (and touched) when absent. */
|
|
542
|
+
export function sessionState(state: GuardState, sessionId: string): SessionState {
|
|
543
|
+
const existing = state.sessions[sessionId];
|
|
544
|
+
const entry = existing ?? freshSession(state.current_session.started || "", state.current_session.git_head ?? null);
|
|
545
|
+
entry.updated = new Date().toISOString();
|
|
546
|
+
state.sessions[sessionId] = entry;
|
|
547
|
+
return entry;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function numberOrZero(candidate: unknown): number {
|
|
551
|
+
return typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0 ? candidate : 0;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function readWatermarks(raw: unknown): SessionState["notify_watermarks"] {
|
|
555
|
+
const value =
|
|
556
|
+
raw !== null && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
557
|
+
return {
|
|
558
|
+
ctx_pct: numberOrZero(value.ctx_pct),
|
|
559
|
+
diff_lines: numberOrZero(value.diff_lines),
|
|
560
|
+
diff_files: numberOrZero(value.diff_files),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function readSession(raw: unknown): SessionState | null {
|
|
565
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
566
|
+
const value = raw as Record<string, unknown>;
|
|
567
|
+
const gitHead = value.git_head;
|
|
568
|
+
return {
|
|
569
|
+
started: typeof value.started === "string" ? value.started : "",
|
|
570
|
+
git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
|
|
571
|
+
brief_digest: typeof value.brief_digest === "string" ? value.brief_digest : null,
|
|
572
|
+
notify_watermarks: readWatermarks(value.notify_watermarks),
|
|
573
|
+
updated: typeof value.updated === "string" ? value.updated : undefined,
|
|
448
574
|
};
|
|
449
575
|
}
|
|
450
576
|
|
|
@@ -458,16 +584,29 @@ export function loadState(cwd: string): GuardState | null {
|
|
|
458
584
|
!Array.isArray(value.current_session)
|
|
459
585
|
? (value.current_session as Record<string, unknown>)
|
|
460
586
|
: {};
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
587
|
+
const gitHead = rawSession.git_head;
|
|
588
|
+
const sessions: Record<string, SessionState> = {};
|
|
589
|
+
if (value.sessions !== null && typeof value.sessions === "object" && !Array.isArray(value.sessions)) {
|
|
590
|
+
for (const [id, entry] of Object.entries(value.sessions as Record<string, unknown>)) {
|
|
591
|
+
const parsed = readSession(entry);
|
|
592
|
+
if (parsed) sessions[id] = parsed;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const currentId = typeof rawSession.id === "string" ? rawSession.id : "";
|
|
596
|
+
// Legacy v1 files kept one digest/watermark pair at the top level; migrate it
|
|
597
|
+
// into the current session's entry in memory rather than dropping it.
|
|
598
|
+
if (currentId !== "" && sessions[currentId] === undefined) {
|
|
599
|
+
const legacyDigest = typeof value.brief_digest === "string" ? value.brief_digest : null;
|
|
600
|
+
const legacyWatermarks = readWatermarks(value.notify_watermarks);
|
|
601
|
+
if (legacyDigest !== null || Object.values(legacyWatermarks).some((n) => n > 0)) {
|
|
602
|
+
sessions[currentId] = {
|
|
603
|
+
started: typeof rawSession.started === "string" ? rawSession.started : "",
|
|
604
|
+
git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
|
|
605
|
+
brief_digest: legacyDigest,
|
|
606
|
+
notify_watermarks: legacyWatermarks,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
471
610
|
const rawCounters =
|
|
472
611
|
value.counters !== null && typeof value.counters === "object" && !Array.isArray(value.counters)
|
|
473
612
|
? (value.counters as Record<string, unknown>)
|
|
@@ -478,23 +617,31 @@ export function loadState(cwd: string): GuardState | null {
|
|
|
478
617
|
counters[key] = candidate;
|
|
479
618
|
}
|
|
480
619
|
}
|
|
481
|
-
const gitHead = rawSession.git_head;
|
|
482
620
|
return {
|
|
483
621
|
current_session: {
|
|
484
|
-
id:
|
|
622
|
+
id: currentId,
|
|
485
623
|
started: typeof rawSession.started === "string" ? rawSession.started : "",
|
|
486
624
|
git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
|
|
487
625
|
},
|
|
488
|
-
|
|
489
|
-
notify_watermarks: {
|
|
490
|
-
ctx_pct: numberOrZero(rawWatermarks.ctx_pct),
|
|
491
|
-
diff_lines: numberOrZero(rawWatermarks.diff_lines),
|
|
492
|
-
diff_files: numberOrZero(rawWatermarks.diff_files),
|
|
493
|
-
},
|
|
626
|
+
sessions,
|
|
494
627
|
counters,
|
|
495
628
|
};
|
|
496
629
|
}
|
|
497
630
|
|
|
631
|
+
/** Persist state.json, keeping the file bounded (SESSION_KEEP entries, the
|
|
632
|
+
* current session always among them so its baseline is never pruned). */
|
|
498
633
|
export function saveState(cwd: string, state: GuardState): void {
|
|
499
|
-
|
|
634
|
+
const currentId = state.current_session.id;
|
|
635
|
+
const ordered = Object.entries(state.sessions).sort(([, a], [, b]) =>
|
|
636
|
+
String(b.updated ?? b.started ?? "").localeCompare(String(a.updated ?? a.started ?? "")),
|
|
637
|
+
);
|
|
638
|
+
const reserve = currentId !== "" && state.sessions[currentId] !== undefined ? 1 : 0;
|
|
639
|
+
const kept: Record<string, SessionState> = {};
|
|
640
|
+
for (const [id, entry] of ordered) {
|
|
641
|
+
if (id === currentId) continue;
|
|
642
|
+
if (Object.keys(kept).length >= SESSION_KEEP - reserve) break;
|
|
643
|
+
kept[id] = entry;
|
|
644
|
+
}
|
|
645
|
+
if (reserve === 1) kept[currentId] = state.sessions[currentId];
|
|
646
|
+
writeJson(statePath(cwd), { ...state, sessions: kept });
|
|
500
647
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protect-context",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Pi extension that adds agent guardrails: context brief, verification evidence, loop blocking, diff-scope warnings, and a durable journal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|