@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 +11 -0
- package/dist/agent-sdk.js +87 -3
- package/dist/index.js +87 -3
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
"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.
|
|
35
|
+
"@odingard/guardian-sdk": "^0.4.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/node": "^22",
|