@odingard/guardian-hook 0.3.1 → 0.4.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 CHANGED
@@ -252,6 +252,17 @@ A deny is the Claude Code deny JSON, plus the reason on stderr and exit 2, both
252
252
  - **The bridge lets the call run if the hook fails to run or crashes.** Only a deny or exit 2 blocks. As with Codex, the hook catches its own crashes, exits 2 on deny, and denies at its own 540-second deadline; the `|| … exit 2` covers a hook that cannot start. A hook killed from outside still fails open.
253
253
  - Not yet confirmed live: the tool names the bridge passes in a hosted dsh image (the source passes dsh's names unchanged; the matcher lists Claude Code's too), whether a host's dsh image already includes the bridge plugin, whether the hook's environment keeps `GUARDIAN_AGENT_CREDENTIAL`, and whether a host's headless runs allow a 5-minute wait. See `examples/deepseek-harness/`.
254
254
 
255
+ ## Edit-type tools: the target file is pinned (0.4)
256
+
257
+ Approval binds the exact arguments. A file path is an argument; for most tools the file's contents are not. For Edit, MultiEdit, NotebookEdit and DeepSeek Harness `edit`, whose result depends on the file being edited, the hook reads the target file when the call is proposed and puts its SHA-256 in the approved arguments (`guardianContentPins`, or `absent: true` for a file that does not exist yet). Just before it releases the approved call it checks the file again. If the file changed, appeared or disappeared, the call is denied with `CONTENT_CHANGED_SINCE_APPROVAL`, nothing runs and the capability is not redeemed. Guardian's servers never see the file.
258
+
259
+ - What this checks is that the file matched when the approved call was released. The agent's own Edit tool then reads the file again after the hook returns, so a short window remains.
260
+ - The target path must be absolute; the hook does not resolve it against the working directory. An Edit-type call with a relative path is denied (`CONTENT_PIN_INVALID_ARGUMENT`). Claude Code always sends absolute paths.
261
+ - Write and DeepSeek `write` are not pinned: they replace the whole file, so its current contents do not change what runs. Their arguments and tiers are unchanged.
262
+ - Shell commands are not pinned. The command text is bound; files it names (`bash deploy.sh`, `curl -F file=@x`, globs, `$VAR`) are not.
263
+ - Pinning is supported on Linux and macOS. On Windows an Edit-type call is denied (`CONTENT_PIN_UNSUPPORTED_PLATFORM`).
264
+ - **Digest privacy:** the SHA-256 of the target is sent to Guardian, shown to the approver and kept in receipts. For a small or low-entropy file, anyone who sees the digest can guess the contents offline by hashing candidates.
265
+
255
266
  ## Limits (read these)
256
267
 
257
268
  - **Decision-gated.** The agent performs the effect after Guardian authorizes and redeems the call. The receipt proves Guardian's authorization of that exact call, not the command's own outcome.
package/dist/agent-sdk.js CHANGED
@@ -3,6 +3,11 @@ import { createGuardianClient } from "@odingard/guardian-sdk";
3
3
 
4
4
  // src/classify.ts
5
5
  import { createHash } from "node:crypto";
6
+ import {
7
+ CONTENT_PIN_INVALID_ARGUMENT,
8
+ CONTENT_PIN_KEY,
9
+ pinFile
10
+ } from "@odingard/guardian-sdk";
6
11
 
7
12
  // src/shell.ts
8
13
  var MAX_NESTING = 16;
@@ -1244,6 +1249,54 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
1244
1249
  "edit"
1245
1250
  // DeepSeek Harness
1246
1251
  ]);
1252
+ var CONTENT_PIN_TOOLS = /* @__PURE__ */ new Set([
1253
+ "Edit",
1254
+ "MultiEdit",
1255
+ "NotebookEdit",
1256
+ "edit"
1257
+ // DeepSeek Harness
1258
+ ]);
1259
+ var EditPinRefused = class extends Error {
1260
+ constructor(reasonCode, message) {
1261
+ super(message);
1262
+ this.reasonCode = reasonCode;
1263
+ this.name = "EditPinRefused";
1264
+ }
1265
+ reasonCode;
1266
+ };
1267
+ async function pinEditTarget(call, governed) {
1268
+ if (!CONTENT_PIN_TOOLS.has(call.tool)) return governed;
1269
+ const path = governed.arguments.path;
1270
+ if (typeof path !== "string" || !path.startsWith("/")) {
1271
+ throw new EditPinRefused(
1272
+ CONTENT_PIN_INVALID_ARGUMENT,
1273
+ `${call.tool} needs an absolute file path; the hook does not resolve relative paths`
1274
+ );
1275
+ }
1276
+ let pin;
1277
+ try {
1278
+ pin = await pinFile(path, { allowAbsent: true });
1279
+ } catch (err) {
1280
+ const code = typeof err?.reasonCode === "string" ? err.reasonCode : "CONTENT_PIN_UNREADABLE";
1281
+ throw new EditPinRefused(
1282
+ code,
1283
+ `the current contents of ${path} could not be pinned`
1284
+ );
1285
+ }
1286
+ const record = "absent" in pin ? { arg: "path", index: 0, mode: "recheck", absent: true } : {
1287
+ arg: "path",
1288
+ index: 0,
1289
+ mode: "recheck",
1290
+ name: pin.name,
1291
+ size: pin.size,
1292
+ sha256: pin.sha256
1293
+ };
1294
+ return {
1295
+ ...governed,
1296
+ arguments: { ...governed.arguments, [CONTENT_PIN_KEY]: [record] },
1297
+ contentPin: { path, pin }
1298
+ };
1299
+ }
1247
1300
  function commandText(value) {
1248
1301
  if (typeof value === "string") return value;
1249
1302
  if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
@@ -1371,6 +1424,7 @@ function tierOf(toolName) {
1371
1424
  }
1372
1425
 
1373
1426
  // src/decide.ts
1427
+ import { verifyPin } from "@odingard/guardian-sdk";
1374
1428
  var PLAIN = {
1375
1429
  NOT_AUTHORIZED: "this agent is not authorized for it",
1376
1430
  APPROVAL_DENIED: "the approver declined it",
@@ -1381,6 +1435,16 @@ var PLAIN = {
1381
1435
  SERVICE_UNAVAILABLE: "Guardian could not be reached",
1382
1436
  REQUEST_CONFLICT: "Guardian saw a conflicting request"
1383
1437
  };
1438
+ async function contentPinRefusal(call) {
1439
+ if (!call.contentPin) return void 0;
1440
+ try {
1441
+ await verifyPin(call.contentPin.path, call.contentPin.pin);
1442
+ return void 0;
1443
+ } catch (err) {
1444
+ const code = err?.reasonCode;
1445
+ return typeof code === "string" ? code : "CONTENT_PIN_UNREADABLE";
1446
+ }
1447
+ }
1384
1448
  function plain(code) {
1385
1449
  return PLAIN[code] ?? code;
1386
1450
  }
@@ -1461,6 +1525,17 @@ async function decide(call, options) {
1461
1525
  guardianDecision: "DENY"
1462
1526
  });
1463
1527
  }
1528
+ const pinRefusal = await contentPinRefusal(call);
1529
+ if (pinRefusal !== void 0) {
1530
+ const path = call.contentPin.path;
1531
+ return {
1532
+ decision: "deny",
1533
+ reason: pinRefusal === "CONTENT_CHANGED_SINCE_APPROVAL" ? `Guardian hook: ${path} changed since this edit was proposed (CONTENT_CHANGED_SINCE_APPROVAL). Nothing ran. Read the file again and propose the edit again; it needs a new approval.` : `Guardian hook: ${path} could not be checked against its approval (${pinRefusal}). Nothing ran.`,
1534
+ requestId: result.requestId,
1535
+ guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
1536
+ approvedByHuman
1537
+ };
1538
+ }
1464
1539
  let executed;
1465
1540
  try {
1466
1541
  executed = await options.client.execute({
@@ -1587,12 +1662,21 @@ async function evaluate(dialect, stdinText, env, deps, deadlineMs) {
1587
1662
  if (!input || typeof input.tool_name !== "string") {
1588
1663
  return failClosed("the tool call had no tool name. Nothing ran.");
1589
1664
  }
1590
- const call = governedCall({
1665
+ const toolCall = {
1591
1666
  tool: input.tool_name,
1592
1667
  input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
1593
1668
  ...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
1594
- });
1595
- if (call === null) return null;
1669
+ };
1670
+ const classified = governedCall(toolCall);
1671
+ if (classified === null) return null;
1672
+ let call;
1673
+ try {
1674
+ call = await pinEditTarget(toolCall, classified);
1675
+ } catch (err) {
1676
+ const code = err instanceof EditPinRefused ? err.reasonCode : "CONTENT_PIN_UNREADABLE";
1677
+ const detail = err instanceof EditPinRefused ? err.message : "the target file could not be pinned";
1678
+ return failClosed(`${detail} (${code}). Nothing ran.`);
1679
+ }
1596
1680
  const connectionId = connectionFor(call.toolName, env);
1597
1681
  const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
1598
1682
  const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
package/dist/index.js CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  // src/classify.ts
4
4
  import { createHash } from "node:crypto";
5
+ import {
6
+ CONTENT_PIN_INVALID_ARGUMENT,
7
+ CONTENT_PIN_KEY,
8
+ pinFile
9
+ } from "@odingard/guardian-sdk";
5
10
 
6
11
  // src/shell.ts
7
12
  var MAX_NESTING = 16;
@@ -1243,6 +1248,54 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
1243
1248
  "edit"
1244
1249
  // DeepSeek Harness
1245
1250
  ]);
1251
+ var CONTENT_PIN_TOOLS = /* @__PURE__ */ new Set([
1252
+ "Edit",
1253
+ "MultiEdit",
1254
+ "NotebookEdit",
1255
+ "edit"
1256
+ // DeepSeek Harness
1257
+ ]);
1258
+ var EditPinRefused = class extends Error {
1259
+ constructor(reasonCode, message) {
1260
+ super(message);
1261
+ this.reasonCode = reasonCode;
1262
+ this.name = "EditPinRefused";
1263
+ }
1264
+ reasonCode;
1265
+ };
1266
+ async function pinEditTarget(call, governed) {
1267
+ if (!CONTENT_PIN_TOOLS.has(call.tool)) return governed;
1268
+ const path = governed.arguments.path;
1269
+ if (typeof path !== "string" || !path.startsWith("/")) {
1270
+ throw new EditPinRefused(
1271
+ CONTENT_PIN_INVALID_ARGUMENT,
1272
+ `${call.tool} needs an absolute file path; the hook does not resolve relative paths`
1273
+ );
1274
+ }
1275
+ let pin;
1276
+ try {
1277
+ pin = await pinFile(path, { allowAbsent: true });
1278
+ } catch (err) {
1279
+ const code = typeof err?.reasonCode === "string" ? err.reasonCode : "CONTENT_PIN_UNREADABLE";
1280
+ throw new EditPinRefused(
1281
+ code,
1282
+ `the current contents of ${path} could not be pinned`
1283
+ );
1284
+ }
1285
+ const record = "absent" in pin ? { arg: "path", index: 0, mode: "recheck", absent: true } : {
1286
+ arg: "path",
1287
+ index: 0,
1288
+ mode: "recheck",
1289
+ name: pin.name,
1290
+ size: pin.size,
1291
+ sha256: pin.sha256
1292
+ };
1293
+ return {
1294
+ ...governed,
1295
+ arguments: { ...governed.arguments, [CONTENT_PIN_KEY]: [record] },
1296
+ contentPin: { path, pin }
1297
+ };
1298
+ }
1246
1299
  function commandText(value) {
1247
1300
  if (typeof value === "string") return value;
1248
1301
  if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
@@ -1373,6 +1426,7 @@ function tierOf(toolName) {
1373
1426
  import { createGuardianClient } from "@odingard/guardian-sdk";
1374
1427
 
1375
1428
  // src/decide.ts
1429
+ import { verifyPin } from "@odingard/guardian-sdk";
1376
1430
  var PLAIN = {
1377
1431
  NOT_AUTHORIZED: "this agent is not authorized for it",
1378
1432
  APPROVAL_DENIED: "the approver declined it",
@@ -1383,6 +1437,16 @@ var PLAIN = {
1383
1437
  SERVICE_UNAVAILABLE: "Guardian could not be reached",
1384
1438
  REQUEST_CONFLICT: "Guardian saw a conflicting request"
1385
1439
  };
1440
+ async function contentPinRefusal(call) {
1441
+ if (!call.contentPin) return void 0;
1442
+ try {
1443
+ await verifyPin(call.contentPin.path, call.contentPin.pin);
1444
+ return void 0;
1445
+ } catch (err) {
1446
+ const code = err?.reasonCode;
1447
+ return typeof code === "string" ? code : "CONTENT_PIN_UNREADABLE";
1448
+ }
1449
+ }
1386
1450
  function plain(code) {
1387
1451
  return PLAIN[code] ?? code;
1388
1452
  }
@@ -1463,6 +1527,17 @@ async function decide(call, options) {
1463
1527
  guardianDecision: "DENY"
1464
1528
  });
1465
1529
  }
1530
+ const pinRefusal = await contentPinRefusal(call);
1531
+ if (pinRefusal !== void 0) {
1532
+ const path = call.contentPin.path;
1533
+ return {
1534
+ decision: "deny",
1535
+ reason: pinRefusal === "CONTENT_CHANGED_SINCE_APPROVAL" ? `Guardian hook: ${path} changed since this edit was proposed (CONTENT_CHANGED_SINCE_APPROVAL). Nothing ran. Read the file again and propose the edit again; it needs a new approval.` : `Guardian hook: ${path} could not be checked against its approval (${pinRefusal}). Nothing ran.`,
1536
+ requestId: result.requestId,
1537
+ guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
1538
+ approvedByHuman
1539
+ };
1540
+ }
1466
1541
  let executed;
1467
1542
  try {
1468
1543
  executed = await options.client.execute({
@@ -1714,12 +1789,21 @@ async function evaluate(dialect2, stdinText, env, deps, deadlineMs) {
1714
1789
  if (!input || typeof input.tool_name !== "string") {
1715
1790
  return failClosed("the tool call had no tool name. Nothing ran.");
1716
1791
  }
1717
- const call = governedCall({
1792
+ const toolCall = {
1718
1793
  tool: input.tool_name,
1719
1794
  input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
1720
1795
  ...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
1721
- });
1722
- if (call === null) return null;
1796
+ };
1797
+ const classified = governedCall(toolCall);
1798
+ if (classified === null) return null;
1799
+ let call;
1800
+ try {
1801
+ call = await pinEditTarget(toolCall, classified);
1802
+ } catch (err) {
1803
+ const code = err instanceof EditPinRefused ? err.reasonCode : "CONTENT_PIN_UNREADABLE";
1804
+ const detail = err instanceof EditPinRefused ? err.message : "the target file could not be pinned";
1805
+ return failClosed(`${detail} (${code}). Nothing ran.`);
1806
+ }
1723
1807
  const connectionId = connectionFor(call.toolName, env);
1724
1808
  const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
1725
1809
  const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odingard/guardian-hook",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Guardian for coding agents: a Claude Code PreToolUse hook (also for OpenAI Codex CLI, DeepSeek Harness and Claude Agent SDK agents) that sends shell commands, file writes and MCP calls to Guardian for a deterministic ALLOW/ASK/DENY decision. Deploys and destructive commands wait for a human approval on your phone; every allowed call carries a verifiable receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Odingard Security LLC",
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "npm run build && npm test"
33
33
  },
34
34
  "dependencies": {
35
- "@odingard/guardian-sdk": "^0.3.8"
35
+ "@odingard/guardian-sdk": "^0.4.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22",