@lannguyensi/harness 0.25.2 → 0.27.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/CHANGELOG.md +36 -0
- package/dist/cli/approve/risk.d.ts +43 -0
- package/dist/cli/approve/risk.js +126 -0
- package/dist/cli/approve/risk.js.map +1 -0
- package/dist/cli/audit.js +8 -2
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/doctor/format.js +24 -0
- package/dist/cli/doctor/format.js.map +1 -1
- package/dist/cli/doctor/index.js +26 -0
- package/dist/cli/doctor/index.js.map +1 -1
- package/dist/cli/doctor/types.d.ts +23 -0
- package/dist/cli/event-input.d.ts +28 -0
- package/dist/cli/event-input.js +73 -0
- package/dist/cli/event-input.js.map +1 -0
- package/dist/cli/explain-action.d.ts +20 -0
- package/dist/cli/explain-action.js +27 -0
- package/dist/cli/explain-action.js.map +1 -0
- package/dist/cli/explain-policy.d.ts +54 -0
- package/dist/cli/explain-policy.js +81 -0
- package/dist/cli/explain-policy.js.map +1 -0
- package/dist/cli/explain.js +4 -0
- package/dist/cli/explain.js.map +1 -1
- package/dist/cli/index.js +126 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +98 -0
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.js +1 -1
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js +1 -1
- package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-post-tool-use.js +1 -1
- package/dist/cli/pack/hook-post-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/pack/hook-track-active-claim.js +1 -1
- package/dist/cli/pack/hook-track-active-claim.js.map +1 -1
- package/dist/cli/{pack/pause-check.d.ts → pause-check.d.ts} +1 -1
- package/dist/cli/{pack/pause-check.js → pause-check.js} +14 -11
- package/dist/cli/pause-check.js.map +1 -0
- package/dist/cli/policy/intercept.d.ts +15 -0
- package/dist/cli/policy/intercept.js +55 -1
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/resolve-env.d.ts +32 -0
- package/dist/cli/resolve-env.js +47 -0
- package/dist/cli/resolve-env.js.map +1 -0
- package/dist/cli/test-risk.d.ts +26 -0
- package/dist/cli/test-risk.js +34 -0
- package/dist/cli/test-risk.js.map +1 -0
- package/dist/runtime/action-envelope.d.ts +64 -0
- package/dist/runtime/action-envelope.js +46 -0
- package/dist/runtime/action-envelope.js.map +1 -0
- package/dist/runtime/environment-resolver.d.ts +36 -0
- package/dist/runtime/environment-resolver.js +138 -0
- package/dist/runtime/environment-resolver.js.map +1 -0
- package/dist/runtime/index.d.ts +6 -1
- package/dist/runtime/index.js +6 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.d.ts +60 -3
- package/dist/runtime/intercept.js +104 -6
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/kube-context.d.ts +16 -0
- package/dist/runtime/kube-context.js +63 -0
- package/dist/runtime/kube-context.js.map +1 -0
- package/dist/runtime/ledger-record.d.ts +8 -0
- package/dist/runtime/ledger-record.js +2 -0
- package/dist/runtime/ledger-record.js.map +1 -1
- package/dist/runtime/risk-classifier.d.ts +38 -0
- package/dist/runtime/risk-classifier.js +148 -0
- package/dist/runtime/risk-classifier.js.map +1 -0
- package/dist/runtime/when-eval.d.ts +40 -0
- package/dist/runtime/when-eval.js +134 -0
- package/dist/runtime/when-eval.js.map +1 -0
- package/dist/schema/environments.d.ts +215 -0
- package/dist/schema/environments.js +101 -0
- package/dist/schema/environments.js.map +1 -0
- package/dist/schema/index.d.ts +419 -11
- package/dist/schema/index.js +8 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/policies.d.ts +152 -13
- package/dist/schema/policies.js +52 -1
- package/dist/schema/policies.js.map +1 -1
- package/dist/schema/risk.d.ts +131 -0
- package/dist/schema/risk.js +87 -0
- package/dist/schema/risk.js.map +1 -0
- package/package.json +1 -1
- package/dist/cli/pack/pause-check.js.map +0 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Phase 7 #3 — `harness test-risk` CLI entrypoint.
|
|
2
|
+
//
|
|
3
|
+
// Debug verb for the Risk Gate. Reads a tool-event JSON file, builds
|
|
4
|
+
// the Action Envelope (#2), classifies it against the manifest's
|
|
5
|
+
// `risk.classifiers[]` (#3), and prints the resulting risk profile.
|
|
6
|
+
// The inspection surface for the Risk Classifier, the counterpart of
|
|
7
|
+
// `harness explain-action` one stage further down the pipeline.
|
|
8
|
+
//
|
|
9
|
+
// File read, JSON guards, and envelope build are the shared
|
|
10
|
+
// `event-input` front end; manifest load + classification + rendering
|
|
11
|
+
// live here.
|
|
12
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
13
|
+
import { classifyRisk } from "../runtime/index.js";
|
|
14
|
+
import { loadEventEnvelope } from "./event-input.js";
|
|
15
|
+
import { loadManifest } from "./loader.js";
|
|
16
|
+
/**
|
|
17
|
+
* Build the Action Envelope for a tool-event JSON file, classify it
|
|
18
|
+
* against the manifest's risk classifiers, and render the profile.
|
|
19
|
+
*
|
|
20
|
+
* Throws `HarnessExitError(EX_NOINPUT)` when the event file is missing,
|
|
21
|
+
* malformed, or not a JSON object (see `loadEventEnvelope`). A manifest
|
|
22
|
+
* with no `risk.classifiers[]` is valid: every action then classifies
|
|
23
|
+
* as unclassified ("unknown is not safe").
|
|
24
|
+
*/
|
|
25
|
+
export function testRisk(opts) {
|
|
26
|
+
const { envelope } = loadEventEnvelope(opts.eventPath, opts, "test-risk");
|
|
27
|
+
const manifest = opts.manifest ?? loadManifest(opts).manifest;
|
|
28
|
+
const profile = classifyRisk(envelope, manifest.risk.classifiers);
|
|
29
|
+
const output = opts.json
|
|
30
|
+
? `${JSON.stringify(profile, null, 2)}\n`
|
|
31
|
+
: stringifyYaml(profile, { lineWidth: 0 });
|
|
32
|
+
return { output, profile };
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=test-risk.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-risk.js","sourceRoot":"","sources":["../../src/cli/test-risk.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,EAAE;AACF,qEAAqE;AACrE,iEAAiE;AACjE,oEAAoE;AACpE,qEAAqE;AACrE,gEAAgE;AAChE,EAAE;AACF,4DAA4D;AAC5D,sEAAsE;AACtE,aAAa;AAEb,OAAO,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,MAAM,CAAC;AAClD,OAAO,EAAE,YAAY,EAAoB,MAAM,qBAAqB,CAAC;AAErE,OAAO,EAAE,iBAAiB,EAAwB,MAAM,kBAAkB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAsB,MAAM,aAAa,CAAC;AAgB/D;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAqB;IAC5C,MAAM,EAAE,QAAQ,EAAE,GAAG,iBAAiB,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC;IAC9D,MAAM,OAAO,GAAG,YAAY,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAClE,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI;QACtB,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI;QACzC,CAAC,CAAC,aAAa,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC;IAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AAC7B,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GitRepoContext } from "./git-context.js";
|
|
2
|
+
import type { ToolEvent } from "./intercept.js";
|
|
3
|
+
export interface ActionEnvelopeSession {
|
|
4
|
+
/** Grounding / Claude Code session id, or "" when absent. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Work-tree basename for the event's cwd, or "" when not in a repo. */
|
|
7
|
+
repo: string;
|
|
8
|
+
/** Current branch, or "" when not in a repo or HEAD is detached. */
|
|
9
|
+
branch: string;
|
|
10
|
+
/**
|
|
11
|
+
* agent-tasks task id. Not present in the Claude Code PreToolUse
|
|
12
|
+
* payload, so "" in practice; a harness-driven or synthetic event may
|
|
13
|
+
* carry one. The MVP does no agent-tasks lookup.
|
|
14
|
+
*/
|
|
15
|
+
task_id: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ActionEnvelopeRuntime {
|
|
18
|
+
/** Working directory the action runs in. */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** OS user, or "" when unavailable. */
|
|
21
|
+
user: string;
|
|
22
|
+
/** Host name, or "" when unavailable. */
|
|
23
|
+
host: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ActionEnvelope {
|
|
26
|
+
/** Hook event name, e.g. "PreToolUse". "" when absent. */
|
|
27
|
+
event: string;
|
|
28
|
+
/** Tool name, e.g. "Bash". "" when absent. */
|
|
29
|
+
tool: string;
|
|
30
|
+
/** The tool's raw input, verbatim. `null` when the event carries none. */
|
|
31
|
+
raw_input: unknown;
|
|
32
|
+
session: ActionEnvelopeSession;
|
|
33
|
+
runtime: ActionEnvelopeRuntime;
|
|
34
|
+
/** ISO-8601 UTC timestamp the envelope was built. */
|
|
35
|
+
timestamp: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ambient facts the builder cannot derive from the event alone. The CLI
|
|
39
|
+
* wrapper resolves these (filesystem + process reads) and hands them in,
|
|
40
|
+
* keeping `buildActionEnvelope` itself pure and I/O-free — the same
|
|
41
|
+
* resolved-by-the-wrapper pattern `intercept()` uses for the git sha and
|
|
42
|
+
* the policy builtins.
|
|
43
|
+
*/
|
|
44
|
+
export interface EnvelopeContext {
|
|
45
|
+
/** Final working directory: the event's cwd, or the wrapper's fallback. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
/** Git context resolved against `cwd`. Empty strings when cwd is not in a repo. */
|
|
48
|
+
git: GitRepoContext;
|
|
49
|
+
/** OS user (`os.userInfo().username`), or "" when unavailable. */
|
|
50
|
+
user: string;
|
|
51
|
+
/** Host name (`os.hostname()`), or "" when unavailable. */
|
|
52
|
+
host: string;
|
|
53
|
+
/** Timestamp to stamp on the envelope. */
|
|
54
|
+
now: Date;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a runtime tool event into an Action Envelope.
|
|
58
|
+
*
|
|
59
|
+
* Pure: every non-deterministic input (cwd, git, user, host, now)
|
|
60
|
+
* arrives via `context`. A sparse or malformed event never throws —
|
|
61
|
+
* absent fields become "" (or `null` for `raw_input`), so a hand-probed
|
|
62
|
+
* `{}` event still yields a well-formed envelope.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildActionEnvelope(event: ToolEvent, context: EnvelopeContext): ActionEnvelope;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Phase 7 #2 — Action Envelope.
|
|
2
|
+
//
|
|
3
|
+
// The normalized, stable representation of a tool call that the Risk
|
|
4
|
+
// Gate pipeline reasons about. The raw runtime event (`ToolEvent`, the
|
|
5
|
+
// Claude Code PreToolUse hook payload) is runtime-specific and loosely
|
|
6
|
+
// shaped; every downstream Risk Gate stage — the Risk Classifier (#3),
|
|
7
|
+
// Context Resolver (#4), Policy Evaluator (#5) — consumes THIS shape
|
|
8
|
+
// instead, so none of them re-parse a runtime-specific payload.
|
|
9
|
+
//
|
|
10
|
+
// STATUS: built by `harness explain-action` (Phase 7 #2). NOT yet
|
|
11
|
+
// consumed by `harness policy intercept` — routing the runtime through
|
|
12
|
+
// the envelope is Phase 7 #5. See docs/risk-gate.md and docs/ROADMAP.md.
|
|
13
|
+
//
|
|
14
|
+
// Design source: lava-ice-logs/2026-04-30/harness-risk-gate-extension.md
|
|
15
|
+
// ("Action Envelope" section).
|
|
16
|
+
function asString(v) {
|
|
17
|
+
return typeof v === "string" ? v : "";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a runtime tool event into an Action Envelope.
|
|
21
|
+
*
|
|
22
|
+
* Pure: every non-deterministic input (cwd, git, user, host, now)
|
|
23
|
+
* arrives via `context`. A sparse or malformed event never throws —
|
|
24
|
+
* absent fields become "" (or `null` for `raw_input`), so a hand-probed
|
|
25
|
+
* `{}` event still yields a well-formed envelope.
|
|
26
|
+
*/
|
|
27
|
+
export function buildActionEnvelope(event, context) {
|
|
28
|
+
return {
|
|
29
|
+
event: asString(event.hook_event_name),
|
|
30
|
+
tool: asString(event.tool_name),
|
|
31
|
+
raw_input: event.tool_input ?? null,
|
|
32
|
+
session: {
|
|
33
|
+
id: asString(event.session_id),
|
|
34
|
+
repo: context.git.repo,
|
|
35
|
+
branch: context.git.branch,
|
|
36
|
+
task_id: asString(event.task_id),
|
|
37
|
+
},
|
|
38
|
+
runtime: {
|
|
39
|
+
cwd: context.cwd,
|
|
40
|
+
user: context.user,
|
|
41
|
+
host: context.host,
|
|
42
|
+
},
|
|
43
|
+
timestamp: context.now.toISOString(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=action-envelope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"action-envelope.js","sourceRoot":"","sources":["../../src/runtime/action-envelope.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,EAAE;AACF,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,uEAAuE;AACvE,qEAAqE;AACrE,gEAAgE;AAChE,EAAE;AACF,kEAAkE;AAClE,uEAAuE;AACvE,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,+BAA+B;AA8D/B,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAgB,EAChB,OAAwB;IAExB,OAAO;QACL,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,eAAe,CAAC;QACtC,IAAI,EAAE,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC;QAC/B,SAAS,EAAE,KAAK,CAAC,UAAU,IAAI,IAAI;QACnC,OAAO,EAAE;YACP,EAAE,EAAE,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC;YAC9B,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI;YACtB,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM;YAC1B,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC;SACjC;QACD,OAAO,EAAE;YACP,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,IAAI,EAAE,OAAO,CAAC,IAAI;SACnB;QACD,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE;KACrC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { EnvironmentResolver, MatchableEnvironment } from "../schema/index.js";
|
|
2
|
+
import type { ActionEnvelope } from "./action-envelope.js";
|
|
3
|
+
export type EnvironmentConfidence = "high" | "medium" | "low";
|
|
4
|
+
export interface EnvironmentResolution {
|
|
5
|
+
/** Resolved environment, or `unknown` when no resolver matched. */
|
|
6
|
+
name: MatchableEnvironment;
|
|
7
|
+
/**
|
|
8
|
+
* `high` when two or more signals (unioned across every resolver
|
|
9
|
+
* asserting the winning environment) back the result, `medium` for a
|
|
10
|
+
* single signal, `low` for `unknown` (no signal matched at all).
|
|
11
|
+
*/
|
|
12
|
+
confidence: EnvironmentConfidence;
|
|
13
|
+
/** Matched signal descriptors that back the result, for explainability. */
|
|
14
|
+
signals: string[];
|
|
15
|
+
/** Name of the resolver whose environment won, or `null` when unresolved. */
|
|
16
|
+
resolver: string | null;
|
|
17
|
+
}
|
|
18
|
+
/** Ambient inputs the resolver matches signals against. */
|
|
19
|
+
export interface SignalInputs {
|
|
20
|
+
/** Environment variables, for `env_var_patterns`. */
|
|
21
|
+
env: Record<string, string | undefined>;
|
|
22
|
+
/** Current kube context name, for `kube_context_patterns`. "" when unknown. */
|
|
23
|
+
kubeContext: string;
|
|
24
|
+
/** Current kube namespace, for `kube_namespace_patterns`. "" when unknown. */
|
|
25
|
+
kubeNamespace: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the target environment of an Action Envelope.
|
|
29
|
+
*
|
|
30
|
+
* Pure: envelope + resolvers + ambient inputs in, resolution out, no
|
|
31
|
+
* I/O. When resolvers disagree, the most-dangerous environment wins
|
|
32
|
+
* (`ENV_PRECEDENCE`). Signals from every fired resolver that asserts the
|
|
33
|
+
* winning environment are unioned for explainability. When nothing
|
|
34
|
+
* matches, the result is `unknown` — deliberately not a safe default.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveEnvironment(envelope: ActionEnvelope, resolvers: readonly EnvironmentResolver[], inputs: SignalInputs): EnvironmentResolution;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Phase 7 #4 — Context Resolver.
|
|
2
|
+
//
|
|
3
|
+
// Classifies an Action Envelope's target environment by matching the
|
|
4
|
+
// manifest's `environments.resolvers[]` signals. The Risk Gate stage
|
|
5
|
+
// that reads the `environments:` schema vocabulary shipped in
|
|
6
|
+
// Phase 7 #1.
|
|
7
|
+
//
|
|
8
|
+
// STATUS: invoked by `harness resolve-env` (Phase 7 #4). NOT yet
|
|
9
|
+
// consumed by `harness policy intercept` — wiring the runtime through
|
|
10
|
+
// the resolver is Phase 7 #5. See docs/risk-gate.md and docs/ROADMAP.md.
|
|
11
|
+
//
|
|
12
|
+
// "Unknown is not safe": when no resolver matches, the environment is
|
|
13
|
+
// `unknown` — not a safe default. The Phase 7 #5 policy evaluator must
|
|
14
|
+
// treat `unknown` as risk-bearing. `MatchableEnvironmentSchema` already
|
|
15
|
+
// admits `unknown` so a `policy.when.environment.name` clause can gate
|
|
16
|
+
// on it.
|
|
17
|
+
//
|
|
18
|
+
// Design source: lava-ice-logs/2026-04-30/harness-risk-gate-extension.md
|
|
19
|
+
// (design phase C).
|
|
20
|
+
// Most-dangerous-wins precedence: when resolvers disagree, the earlier
|
|
21
|
+
// entry here is the resolved environment. `unknown` is the implicit
|
|
22
|
+
// fallback and is not a resolver-assertable value.
|
|
23
|
+
const ENV_PRECEDENCE = [
|
|
24
|
+
"production",
|
|
25
|
+
"staging",
|
|
26
|
+
"dev",
|
|
27
|
+
"local",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Convert a `*`-glob to an anchored RegExp. Only `*` is special (the
|
|
31
|
+
* Phase 7 #1 signal patterns are simple, e.g. `main`, `release/*`); all
|
|
32
|
+
* other regex metacharacters are escaped literally.
|
|
33
|
+
*/
|
|
34
|
+
function globToRegExp(glob) {
|
|
35
|
+
const escaped = glob
|
|
36
|
+
.replace(/[.+^${}()|[\]\\?]/g, "\\$&")
|
|
37
|
+
.replace(/\*/g, ".*");
|
|
38
|
+
return new RegExp(`^${escaped}$`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Match one resolver's signals against the envelope + ambient inputs.
|
|
42
|
+
* Signals are OR-ed: a resolver fires when ANY signal matches. Returns
|
|
43
|
+
* the matched signal descriptors (empty when the resolver did not fire).
|
|
44
|
+
*
|
|
45
|
+
* Per-signal-kind semantics: `branch_patterns` and
|
|
46
|
+
* `kube_namespace_patterns` are globs, `kube_context_patterns` are
|
|
47
|
+
* regexes, `env_var_patterns` are substrings of the variable's value.
|
|
48
|
+
*/
|
|
49
|
+
function matchResolver(resolver, envelope, inputs) {
|
|
50
|
+
const matched = [];
|
|
51
|
+
const sig = resolver.signals;
|
|
52
|
+
const branch = envelope.session.branch;
|
|
53
|
+
if (sig.branch_patterns !== undefined && branch !== "") {
|
|
54
|
+
for (const p of sig.branch_patterns) {
|
|
55
|
+
if (globToRegExp(p).test(branch)) {
|
|
56
|
+
matched.push(`branch:${branch} ~ ${p}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (sig.env_var_patterns !== undefined) {
|
|
61
|
+
for (const evp of sig.env_var_patterns) {
|
|
62
|
+
const value = inputs.env[evp.var];
|
|
63
|
+
if (typeof value !== "string")
|
|
64
|
+
continue;
|
|
65
|
+
for (const p of evp.patterns) {
|
|
66
|
+
if (value.includes(p)) {
|
|
67
|
+
matched.push(`env:${evp.var} contains "${p}"`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (sig.kube_context_patterns !== undefined && inputs.kubeContext !== "") {
|
|
73
|
+
for (const p of sig.kube_context_patterns) {
|
|
74
|
+
let re;
|
|
75
|
+
try {
|
|
76
|
+
re = new RegExp(p);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (re.test(inputs.kubeContext)) {
|
|
82
|
+
matched.push(`kube-context:${inputs.kubeContext} ~ /${p}/`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (sig.kube_namespace_patterns !== undefined &&
|
|
87
|
+
inputs.kubeNamespace !== "") {
|
|
88
|
+
for (const p of sig.kube_namespace_patterns) {
|
|
89
|
+
if (globToRegExp(p).test(inputs.kubeNamespace)) {
|
|
90
|
+
matched.push(`kube-namespace:${inputs.kubeNamespace} ~ ${p}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return matched;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the target environment of an Action Envelope.
|
|
98
|
+
*
|
|
99
|
+
* Pure: envelope + resolvers + ambient inputs in, resolution out, no
|
|
100
|
+
* I/O. When resolvers disagree, the most-dangerous environment wins
|
|
101
|
+
* (`ENV_PRECEDENCE`). Signals from every fired resolver that asserts the
|
|
102
|
+
* winning environment are unioned for explainability. When nothing
|
|
103
|
+
* matches, the result is `unknown` — deliberately not a safe default.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveEnvironment(envelope, resolvers, inputs) {
|
|
106
|
+
const fired = [];
|
|
107
|
+
for (const resolver of resolvers) {
|
|
108
|
+
const signals = matchResolver(resolver, envelope, inputs);
|
|
109
|
+
if (signals.length > 0)
|
|
110
|
+
fired.push({ resolver, signals });
|
|
111
|
+
}
|
|
112
|
+
if (fired.length === 0) {
|
|
113
|
+
return { name: "unknown", confidence: "low", signals: [], resolver: null };
|
|
114
|
+
}
|
|
115
|
+
let best = fired[0];
|
|
116
|
+
for (const f of fired) {
|
|
117
|
+
if (ENV_PRECEDENCE.indexOf(f.resolver.environment) <
|
|
118
|
+
ENV_PRECEDENCE.indexOf(best.resolver.environment)) {
|
|
119
|
+
best = f;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Union the signals of every fired resolver that asserts the winning
|
|
123
|
+
// environment, so the explanation is complete when several resolvers
|
|
124
|
+
// agree. `resolver` names the highest-precedence (first-found) one.
|
|
125
|
+
const winningEnv = best.resolver.environment;
|
|
126
|
+
const signals = [
|
|
127
|
+
...new Set(fired
|
|
128
|
+
.filter((f) => f.resolver.environment === winningEnv)
|
|
129
|
+
.flatMap((f) => f.signals)),
|
|
130
|
+
].sort();
|
|
131
|
+
return {
|
|
132
|
+
name: winningEnv,
|
|
133
|
+
confidence: signals.length >= 2 ? "high" : "medium",
|
|
134
|
+
signals,
|
|
135
|
+
resolver: best.resolver.name,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=environment-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"environment-resolver.js","sourceRoot":"","sources":["../../src/runtime/environment-resolver.ts"],"names":[],"mappings":"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"}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
export { intercept, type ClaudeDenyJson, type InterceptOptions, type InterceptResult, type LedgerClient, type PolicyDecision, type PolicyOutcome, type ToolEvent, } from "./intercept.js";
|
|
1
|
+
export { intercept, policyMatchesEvent, type ClaudeDenyJson, type InterceptOptions, type InterceptResult, type LedgerClient, type PolicyDecision, type PolicyOutcome, type RiskGateContext, type ToolEvent, } from "./intercept.js";
|
|
2
|
+
export { evaluateWhen, type WhenClauseKey, type WhenClauseResult, type WhenContext, type WhenEvaluation, } from "./when-eval.js";
|
|
2
3
|
export { recordPolicyDecision, payloadFromDecision, encodeLedgerContent, decodeLedgerContent, decisionSortKey, type LedgerRecordOptions, type PolicyDecisionPayload, } from "./ledger-record.js";
|
|
3
4
|
export { resolveSessionId } from "./session-id.js";
|
|
4
5
|
export { buildAgentFacingBlock, formatAgentFacingMessage, renderAgentFacing, type AgentFacingBlock, } from "./agent-facing.js";
|
|
5
6
|
export { resolveGitContext, type GitRepoContext } from "./git-context.js";
|
|
7
|
+
export { buildActionEnvelope, type ActionEnvelope, type ActionEnvelopeRuntime, type ActionEnvelopeSession, type EnvelopeContext, } from "./action-envelope.js";
|
|
8
|
+
export { classifyRisk, type RiskConfidence, type RiskProfile, } from "./risk-classifier.js";
|
|
9
|
+
export { resolveKubeContext, type KubeContext, type ResolveKubeContextOptions, } from "./kube-context.js";
|
|
10
|
+
export { resolveEnvironment, type EnvironmentConfidence, type EnvironmentResolution, type SignalInputs, } from "./environment-resolver.js";
|
|
6
11
|
export { addLedgerFact, type AddLedgerFactOptions, type AddLedgerFactResult, } from "./ledger-add.js";
|
package/dist/runtime/index.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
export { intercept, } from "./intercept.js";
|
|
1
|
+
export { intercept, policyMatchesEvent, } from "./intercept.js";
|
|
2
|
+
export { evaluateWhen, } from "./when-eval.js";
|
|
2
3
|
export { recordPolicyDecision, payloadFromDecision, encodeLedgerContent, decodeLedgerContent, decisionSortKey, } from "./ledger-record.js";
|
|
3
4
|
export { resolveSessionId } from "./session-id.js";
|
|
4
5
|
export { buildAgentFacingBlock, formatAgentFacingMessage, renderAgentFacing, } from "./agent-facing.js";
|
|
5
6
|
export { resolveGitContext } from "./git-context.js";
|
|
7
|
+
export { buildActionEnvelope, } from "./action-envelope.js";
|
|
8
|
+
export { classifyRisk, } from "./risk-classifier.js";
|
|
9
|
+
export { resolveKubeContext, } from "./kube-context.js";
|
|
10
|
+
export { resolveEnvironment, } from "./environment-resolver.js";
|
|
6
11
|
export { addLedgerFact, } from "./ledger-add.js";
|
|
7
12
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,kBAAkB,GASnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,YAAY,GAKb,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,GAGhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EACL,qBAAqB,EACrB,wBAAwB,EACxB,iBAAiB,GAElB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,iBAAiB,EAAuB,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EACL,mBAAmB,GAKpB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,YAAY,GAGb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,kBAAkB,GAGnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,kBAAkB,GAInB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,aAAa,GAGd,MAAM,iBAAiB,CAAC"}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type ExtractBuiltins, type LedgerQueryResult } from "../policies/index.js";
|
|
2
|
-
import type { Manifest } from "../schema/index.js";
|
|
2
|
+
import type { Manifest, Policy } from "../schema/index.js";
|
|
3
|
+
import { type EnvironmentResolution } from "./environment-resolver.js";
|
|
4
|
+
import type { GitRepoContext } from "./git-context.js";
|
|
5
|
+
import { type RiskProfile } from "./risk-classifier.js";
|
|
3
6
|
export interface ToolEvent {
|
|
4
7
|
hook_event_name?: string;
|
|
5
8
|
tool_name?: string;
|
|
@@ -8,10 +11,10 @@ export interface ToolEvent {
|
|
|
8
11
|
cwd?: string;
|
|
9
12
|
[key: string]: unknown;
|
|
10
13
|
}
|
|
11
|
-
export type PolicyOutcome = "allow" | "deny" | "warn-degraded";
|
|
14
|
+
export type PolicyOutcome = "allow" | "warn" | "require_approval" | "deny" | "warn-degraded";
|
|
12
15
|
export interface PolicyDecision {
|
|
13
16
|
policyName: string;
|
|
14
|
-
enforcement: "
|
|
17
|
+
enforcement: Policy["enforcement"];
|
|
15
18
|
outcome: PolicyOutcome;
|
|
16
19
|
reason: string;
|
|
17
20
|
extractValues: Record<string, string>;
|
|
@@ -20,6 +23,17 @@ export interface PolicyDecision {
|
|
|
20
23
|
matchedCount: number;
|
|
21
24
|
reason: string;
|
|
22
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Risk Classifier verdict for the action this decision was made
|
|
28
|
+
* about. Present only when the Risk Gate was active for the event
|
|
29
|
+
* (the manifest declared at least one `when:`-bearing policy); absent
|
|
30
|
+
* for a pure Phase-4 manifest, keeping its decisions byte-identical.
|
|
31
|
+
* Recorded to the audit ledger so `harness explain --trace` can
|
|
32
|
+
* replay the classification.
|
|
33
|
+
*/
|
|
34
|
+
risk?: RiskProfile;
|
|
35
|
+
/** Context Resolver verdict, present under the same condition as `risk`. */
|
|
36
|
+
environment?: EnvironmentResolution;
|
|
23
37
|
/**
|
|
24
38
|
* One-line "to satisfy" hint synthesised from the policy's `requires`
|
|
25
39
|
* spec. Carried on the live decision so the deny-envelope formatter
|
|
@@ -84,5 +98,48 @@ export interface InterceptOptions {
|
|
|
84
98
|
* branch falls through to the standard time-window check.
|
|
85
99
|
*/
|
|
86
100
|
currentHeadSha?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Ambient context for the Risk Gate stages — the Action Envelope
|
|
103
|
+
* build (#2) and the environment resolution (#4). Resolved by the CLI
|
|
104
|
+
* wrapper (git / user / host / kube-config / env reads) and threaded
|
|
105
|
+
* in, keeping `intercept()` itself I/O-free, the same
|
|
106
|
+
* resolved-by-the-wrapper pattern as `currentHeadSha` and `builtins`.
|
|
107
|
+
*
|
|
108
|
+
* Optional: omitted by Phase-4-era callers and by unit tests that do
|
|
109
|
+
* not exercise `when:`. When omitted, the envelope is built from the
|
|
110
|
+
* event alone — risk then classifies as unclassified and the
|
|
111
|
+
* environment resolves to `unknown`. A manifest with no `when:`
|
|
112
|
+
* policy never reads any of this regardless (see `intercept`).
|
|
113
|
+
*/
|
|
114
|
+
riskContext?: RiskGateContext;
|
|
87
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Ambient inputs the Risk Gate needs that the CLI wrapper resolves from
|
|
118
|
+
* the host (filesystem + process). Mirrors the `EnvelopeContext` /
|
|
119
|
+
* `SignalInputs` split the debug verbs already use.
|
|
120
|
+
*/
|
|
121
|
+
export interface RiskGateContext {
|
|
122
|
+
/** Git context resolved against the event's cwd. */
|
|
123
|
+
git: GitRepoContext;
|
|
124
|
+
/** Working directory the action runs in. */
|
|
125
|
+
cwd: string;
|
|
126
|
+
/** OS user, or "" when unavailable. */
|
|
127
|
+
user: string;
|
|
128
|
+
/** Host name, or "" when unavailable. */
|
|
129
|
+
host: string;
|
|
130
|
+
/** Environment variables, for resolver `env_var_patterns`. */
|
|
131
|
+
env: Record<string, string | undefined>;
|
|
132
|
+
/** Current kube context name, or "" when unknown. */
|
|
133
|
+
kubeContext: string;
|
|
134
|
+
/** Current kube namespace, or "" when unknown. */
|
|
135
|
+
kubeNamespace: string;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Does a policy's `trigger:` match this event? This is the WHICH-tool-
|
|
139
|
+
* calls filter; the WHETHER-it-applies filter is `policy.when:`,
|
|
140
|
+
* evaluated separately (`evaluateWhen`). A policy fires only when both
|
|
141
|
+
* hold. Exported so `harness explain-policy` can report the trigger
|
|
142
|
+
* verdict on its own.
|
|
143
|
+
*/
|
|
144
|
+
export declare function policyMatchesEvent(policy: Policy, event: ToolEvent): boolean;
|
|
88
145
|
export declare function intercept(options: InterceptOptions): Promise<InterceptResult>;
|
|
@@ -7,11 +7,45 @@
|
|
|
7
7
|
// that wraps this.
|
|
8
8
|
import { evaluateExtract, evaluateRequires, parseDurationSeconds, substituteTemplate, } from "../policies/index.js";
|
|
9
9
|
import { renderProducers } from "../policies/producers.js";
|
|
10
|
+
import { buildActionEnvelope } from "./action-envelope.js";
|
|
10
11
|
import { renderAgentFacing } from "./agent-facing.js";
|
|
12
|
+
import { resolveEnvironment, } from "./environment-resolver.js";
|
|
11
13
|
import { POLICY_DECISION_TYPE } from "./ledger-record.js";
|
|
14
|
+
import { classifyRisk } from "./risk-classifier.js";
|
|
12
15
|
import { resolveSessionId } from "./session-id.js";
|
|
13
16
|
import { expandToolNameAliases, extractShellCommand, } from "./tool-name-aliases.js";
|
|
14
|
-
|
|
17
|
+
import { evaluateWhen } from "./when-eval.js";
|
|
18
|
+
/**
|
|
19
|
+
* Build the Action Envelope for an event and run it through the Risk
|
|
20
|
+
* Classifier (#3) and Context Resolver (#4). Pure: every host fact
|
|
21
|
+
* arrives via `riskContext`; when it is absent the envelope is built
|
|
22
|
+
* from the event alone (unclassified risk, `unknown` environment).
|
|
23
|
+
*/
|
|
24
|
+
function enrichEnvelope(manifest, event, riskContext, now) {
|
|
25
|
+
const rc = riskContext;
|
|
26
|
+
const envelope = buildActionEnvelope(event, {
|
|
27
|
+
cwd: rc?.cwd ?? (typeof event.cwd === "string" ? event.cwd : ""),
|
|
28
|
+
git: rc?.git ?? { repo: "", branch: "", sha: "" },
|
|
29
|
+
user: rc?.user ?? "",
|
|
30
|
+
host: rc?.host ?? "",
|
|
31
|
+
now: now ?? new Date(),
|
|
32
|
+
});
|
|
33
|
+
const risk = classifyRisk(envelope, manifest.risk.classifiers);
|
|
34
|
+
const environment = resolveEnvironment(envelope, manifest.environments.resolvers, {
|
|
35
|
+
env: rc?.env ?? {},
|
|
36
|
+
kubeContext: rc?.kubeContext ?? "",
|
|
37
|
+
kubeNamespace: rc?.kubeNamespace ?? "",
|
|
38
|
+
});
|
|
39
|
+
return { risk, environment };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Does a policy's `trigger:` match this event? This is the WHICH-tool-
|
|
43
|
+
* calls filter; the WHETHER-it-applies filter is `policy.when:`,
|
|
44
|
+
* evaluated separately (`evaluateWhen`). A policy fires only when both
|
|
45
|
+
* hold. Exported so `harness explain-policy` can report the trigger
|
|
46
|
+
* verdict on its own.
|
|
47
|
+
*/
|
|
48
|
+
export function policyMatchesEvent(policy, event) {
|
|
15
49
|
if (policy.trigger.event !== event.hook_event_name)
|
|
16
50
|
return false;
|
|
17
51
|
if (policy.trigger.match !== undefined) {
|
|
@@ -46,6 +80,35 @@ function buildEventContext(event) {
|
|
|
46
80
|
git: {},
|
|
47
81
|
};
|
|
48
82
|
}
|
|
83
|
+
/** Map a failed-`requires` policy to its decision outcome by enforcement. */
|
|
84
|
+
function outcomeForFailedRequires(enforcement) {
|
|
85
|
+
switch (enforcement) {
|
|
86
|
+
case "block":
|
|
87
|
+
return "deny";
|
|
88
|
+
case "warn":
|
|
89
|
+
return "warn";
|
|
90
|
+
case "require_approval":
|
|
91
|
+
return "require_approval";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Does a decision abort the tool call? Phase 7 #6 makes the Risk Gate
|
|
96
|
+
* authoritative at the `PreToolUse` boundary:
|
|
97
|
+
* - `deny` aborts (a `block`-enforcement policy whose requires failed,
|
|
98
|
+
* the Phase 4 mechanism, unchanged).
|
|
99
|
+
* - `require_approval` aborts until the approval evidence exists. In
|
|
100
|
+
* Phase 7 #5 this outcome was returned but did not block; #6 makes
|
|
101
|
+
* it block. The approval tag is satisfiable through the policy's
|
|
102
|
+
* `requires:` (an operator runs `harness approve risk`); once the
|
|
103
|
+
* tag is on record the requires evaluation passes and the outcome
|
|
104
|
+
* is `allow` instead.
|
|
105
|
+
* - `allow` / `warn` / `warn-degraded` never abort.
|
|
106
|
+
*/
|
|
107
|
+
function isBlockingDecision(d) {
|
|
108
|
+
if (d.outcome === "deny")
|
|
109
|
+
return d.enforcement === "block";
|
|
110
|
+
return d.outcome === "require_approval";
|
|
111
|
+
}
|
|
49
112
|
async function evaluateOnePolicy(policy, options) {
|
|
50
113
|
const evaluatedAt = (options.now ?? new Date()).toISOString();
|
|
51
114
|
const ctx = buildEventContext(options.event);
|
|
@@ -130,7 +193,14 @@ async function evaluateOnePolicy(policy, options) {
|
|
|
130
193
|
evaluatedAt,
|
|
131
194
|
};
|
|
132
195
|
}
|
|
133
|
-
|
|
196
|
+
// Four-way decision (Phase 7 #5). A satisfied `requires` always
|
|
197
|
+
// `allow`s; a failed one is mapped by the policy's enforcement —
|
|
198
|
+
// `block` → `deny`, `warn` → `warn`, `require_approval` →
|
|
199
|
+
// `require_approval`. The evaluator only RETURNS `require_approval`
|
|
200
|
+
// here; Phase 7 #6 makes it block.
|
|
201
|
+
const outcome = evaluation.allowed
|
|
202
|
+
? "allow"
|
|
203
|
+
: outcomeForFailedRequires(policy.enforcement);
|
|
134
204
|
return {
|
|
135
205
|
policyName: policy.name,
|
|
136
206
|
enforcement: policy.enforcement,
|
|
@@ -168,19 +238,47 @@ function filterEntriesByTag(entries, tag) {
|
|
|
168
238
|
(e.source !== undefined && e.source.includes(tag))));
|
|
169
239
|
}
|
|
170
240
|
export async function intercept(options) {
|
|
171
|
-
const
|
|
241
|
+
const { manifest, event } = options;
|
|
242
|
+
// The Risk Gate is active only when some policy declares a `when:`
|
|
243
|
+
// block. A manifest with none — every Phase 4 / 5 / 6 manifest — skips
|
|
244
|
+
// envelope enrichment entirely: no `buildActionEnvelope`, no
|
|
245
|
+
// classifier, no resolver, and decisions carry no `risk` / `environment`.
|
|
246
|
+
// That keeps such manifests byte-for-byte identical to pre-Phase-7-#5.
|
|
247
|
+
const riskGateActive = manifest.policies.some((p) => p.when !== undefined);
|
|
248
|
+
const enriched = riskGateActive
|
|
249
|
+
? enrichEnvelope(manifest, event, options.riskContext, options.now)
|
|
250
|
+
: undefined;
|
|
251
|
+
// A policy fires only when its `trigger:` matches AND — when declared
|
|
252
|
+
// — every `when:` clause holds against the enriched envelope.
|
|
253
|
+
const matching = manifest.policies.filter((p) => {
|
|
254
|
+
if (!policyMatchesEvent(p, event))
|
|
255
|
+
return false;
|
|
256
|
+
if (p.when === undefined)
|
|
257
|
+
return true;
|
|
258
|
+
// `enriched` is defined here: a policy with `when:` set `riskGateActive`.
|
|
259
|
+
return evaluateWhen(p.when, enriched).matched;
|
|
260
|
+
});
|
|
172
261
|
const decisions = [];
|
|
173
262
|
for (const policy of matching) {
|
|
174
|
-
const
|
|
263
|
+
const base = await evaluateOnePolicy(policy, options);
|
|
264
|
+
// Attach the per-event Risk Gate verdicts so `harness audit` /
|
|
265
|
+
// `explain --trace` can replay the classification + environment
|
|
266
|
+
// that the `when:` match was made against.
|
|
267
|
+
const decision = enriched
|
|
268
|
+
? { ...base, risk: enriched.risk, environment: enriched.environment }
|
|
269
|
+
: base;
|
|
175
270
|
decisions.push(decision);
|
|
176
271
|
try {
|
|
177
|
-
await options.ledger.record(decision, resolveSessionId(
|
|
272
|
+
await options.ledger.record(decision, resolveSessionId(event.session_id));
|
|
178
273
|
}
|
|
179
274
|
catch {
|
|
180
275
|
/* audit-write failure must not block; the decision is still applied. */
|
|
181
276
|
}
|
|
182
277
|
}
|
|
183
|
-
|
|
278
|
+
// First blocking decision wins the envelope. `deny` and
|
|
279
|
+
// `require_approval` both abort (Phase 7 #6); the search order is the
|
|
280
|
+
// manifest's policy order, same as Phase 4.
|
|
281
|
+
const blocking = decisions.find(isBlockingDecision);
|
|
184
282
|
if (blocking) {
|
|
185
283
|
const sessionId = resolveSessionId(options.event.session_id);
|
|
186
284
|
// Append the "to satisfy" hint so Claude Code's deny message tells
|